Merge pull request #13459 from open-webui/notes

feat: notes
This commit is contained in:
Tim Jaeryang Baek 2025-05-03 11:54:04 -07:00 committed by GitHub
commit 1af45435eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1318 additions and 200 deletions

View File

@ -64,6 +64,7 @@ from open_webui.routers import (
auths,
channels,
chats,
notes,
folders,
configs,
groups,
@ -1000,6 +1001,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"])
app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])

View File

@ -0,0 +1,33 @@
"""Add note table
Revision ID: 9f0c9cd09105
Revises: 3781e22d8b01
Create Date: 2025-05-03 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "9f0c9cd09105"
down_revision = "3781e22d8b01"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"note",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("note")

View File

@ -0,0 +1,135 @@
import json
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
####################
# Note DB Schema
####################
class Note(Base):
__tablename__ = "note"
id = Column(Text, primary_key=True)
user_id = Column(Text)
title = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class NoteModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
class NoteTable:
def insert_new_note(
self,
form_data: NoteForm,
user_id: str,
) -> Optional[NoteModel]:
with get_db() as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
}
)
new_note = Note(**note.model_dump())
db.add(new_note)
db.commit()
return note
def get_notes(self) -> list[NoteModel]:
with get_db() as db:
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[NoteModel]:
notes = self.get_notes()
return [
note
for note in notes
if note.user_id == user_id
or has_access(user_id, permission, note.access_control)
]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
if not note:
return None
note.title = form_data.title
note.data = form_data.data
note.meta = form_data.meta
note.access_control = form_data.access_control
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
def delete_note_by_id(self, id: str):
with get_db() as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
Notes = NoteTable()

View File

@ -0,0 +1,159 @@
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# GetNotes
############################
@router.get("/", response_model=list[NoteUserResponse])
async def get_notes(user=Depends(get_verified_user)):
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "write")
]
return notes
@router.get("/list", response_model=list[NoteUserResponse])
async def get_note_list(user=Depends(get_verified_user)):
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "read")
]
return notes
############################
# CreateNewNote
############################
@router.post("/create", response_model=Optional[NoteModel])
async def create_new_note(form_data: NoteForm, user=Depends(get_admin_user)):
try:
note = Notes.insert_new_note(form_data, user.id)
return note
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetNoteById
############################
@router.get("/{id}", response_model=Optional[NoteModel])
async def get_note_by_id(id: str, user=Depends(get_verified_user)):
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
return note
############################
# UpdateNoteById
############################
@router.post("/{id}/update", response_model=Optional[NoteModel])
async def update_note_by_id(
id: str, form_data: NoteForm, user=Depends(get_verified_user)
):
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="write", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
note = Notes.update_note_by_id(id, form_data)
return note
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteNoteById
############################
@router.delete("/{id}/delete", response_model=bool)
async def delete_note_by_id(id: str, user=Depends(get_verified_user)):
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="write", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
note = Notes.delete_note_by_id(id)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)

187
src/lib/apis/notes/index.ts Normal file
View File

@ -0,0 +1,187 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getTimeRange } from '$lib/utils';
type NoteItem = {
title: string;
data: object;
meta?: null | object;
access_control?: null | object;
};
export const createNewNote = async (token: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNotes = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object
const grouped: Record<string, any[]> = {};
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
export const getNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View File

@ -192,7 +192,7 @@
<svelte:head>
<title>
{$i18n.t('Functions')} | {$WEBUI_NAME}
{$i18n.t('Functions')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@ -195,7 +195,7 @@
</script>
<svelte:head>
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
<title>#{channel?.name ?? 'Channel'} Open WebUI</title>
</svelte:head>
<div

View File

@ -357,14 +357,14 @@
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
onCancel={async () => {
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
onConfirm={async (data) => {
const { text, filename } = data;
content = `${content}${text} `;
recording = false;

View File

@ -1921,7 +1921,7 @@
<svelte:head>
<title>
{$chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>

View File

@ -479,14 +479,14 @@
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
onCancel={async () => {
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
onConfirm={async (data) => {
const { text, filename } = data;
prompt = `${prompt}${text} `;
recording = false;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { config, settings } from '$lib/stores';
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
@ -8,11 +8,13 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let recording = false;
export let transcribe = true;
export let className = ' p-2.5 w-full max-w-full';
export let onCancel = () => {};
export let onConfirm = (data) => {};
let loading = false;
let confirmed = false;
@ -130,20 +132,32 @@
detectSound();
};
const transcribeHandler = async (audioBlob) => {
const onStopHandler = async (audioBlob) => {
// Create a blob from the audio chunks
await tick();
const file = blobToFile(audioBlob, 'recording.wav');
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (transcribe) {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
// with web stt, we don't need to send the file to the server
return;
}
if (res) {
console.log(res);
dispatch('confirm', res);
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
onConfirm(res);
}
} else {
onConfirm({
file: file,
blob: audioBlob
});
}
};
@ -168,6 +182,7 @@
autoGainControl: true
}
});
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.onstart = () => {
console.log('Recording started');
@ -180,77 +195,79 @@
mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
mediaRecorder.onstop = async () => {
console.log('Recording stopped');
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
audioChunks = [];
} else {
if (confirmed) {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
await transcribeHandler(audioBlob);
if (confirmed) {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
await onStopHandler(audioBlob);
confirmed = false;
loading = false;
}
audioChunks = [];
recording = false;
confirmed = false;
loading = false;
}
audioChunks = [];
recording = false;
};
mediaRecorder.start();
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
// Set continuous to true for continuous recognition
speechRecognition.continuous = true;
if (transcribe) {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
const inactivityTimeout = 2000; // 3 seconds
// Set continuous to true for continuous recognition
speechRecognition.continuous = true;
let timeoutId;
// Start recognition
speechRecognition.start();
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
const inactivityTimeout = 2000; // 3 seconds
// Event triggered when speech is recognized
speechRecognition.onresult = async (event) => {
// Clear the inactivity timeout
clearTimeout(timeoutId);
let timeoutId;
// Start recognition
speechRecognition.start();
// Handle recognized speech
console.log(event);
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
// Event triggered when speech is recognized
speechRecognition.onresult = async (event) => {
// Clear the inactivity timeout
clearTimeout(timeoutId);
transcription = `${transcription}${transcript}`;
// Handle recognized speech
console.log(event);
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
await tick();
document.getElementById('chat-input')?.focus();
transcription = `${transcription}${transcript}`;
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
console.log('Speech recognition turned off due to inactivity.');
speechRecognition.stop();
}, inactivityTimeout);
};
await tick();
document.getElementById('chat-input')?.focus();
// Event triggered when recognition is ended
speechRecognition.onend = function () {
// Restart recognition after it ends
console.log('recognition ended');
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
console.log('Speech recognition turned off due to inactivity.');
speechRecognition.stop();
}, inactivityTimeout);
};
confirmRecording();
dispatch('confirm', { text: transcription });
confirmed = false;
loading = false;
};
// Event triggered when recognition is ended
speechRecognition.onend = function () {
// Restart recognition after it ends
console.log('recognition ended');
// Event triggered when an error occurs
speechRecognition.onerror = function (event) {
console.log(event);
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
dispatch('cancel');
confirmRecording();
onConfirm({
text: transcription
});
confirmed = false;
loading = false;
};
stopRecording();
};
// Event triggered when an error occurs
speechRecognition.onerror = function (event) {
console.log(event);
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
onCancel();
stopRecording();
};
}
}
}
};
@ -339,7 +356,7 @@
rounded-full"
on:click={async () => {
stopRecording();
dispatch('cancel');
onCancel();
}}
>
<svg

View File

@ -47,8 +47,8 @@
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full px-1.5">
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full pl-1.5 pr-1">
<div
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div>

View File

@ -29,15 +29,19 @@
export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {};
export let onChange = (e) => {};
// create a lowlight instance with all languages loaded
const lowlight = createLowlight(all);
export let className = 'input-prose';
export let placeholder = 'Type here...';
export let value = '';
export let id = '';
export let id = '';
export let value = '';
export let html = '';
export let json = false;
export let raw = false;
export let preserveBreaks = false;
@ -128,41 +132,43 @@
};
onMount(async () => {
console.log(value);
if (preserveBreaks) {
turndownService.addRule('preserveBreaks', {
filter: 'br', // Target <br> elements
replacement: function (content) {
return '<br/>';
}
});
}
let content = value;
if (!raw) {
async function tryParse(value, attempts = 3, interval = 100) {
try {
// Try parsing the value
return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false
});
} catch (error) {
// If no attempts remain, fallback to plain text
if (attempts <= 1) {
return value;
if (!json) {
if (preserveBreaks) {
turndownService.addRule('preserveBreaks', {
filter: 'br', // Target <br> elements
replacement: function (content) {
return '<br/>';
}
// Wait for the interval, then retry
await new Promise((resolve) => setTimeout(resolve, interval));
return tryParse(value, attempts - 1, interval); // Recursive call
}
});
}
// Usage example
content = await tryParse(value);
if (!raw) {
async function tryParse(value, attempts = 3, interval = 100) {
try {
// Try parsing the value
return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false
});
} catch (error) {
// If no attempts remain, fallback to plain text
if (attempts <= 1) {
return value;
}
// Wait for the interval, then retry
await new Promise((resolve) => setTimeout(resolve, interval));
return tryParse(value, attempts - 1, interval); // Recursive call
}
}
// Usage example
content = await tryParse(value);
}
}
console.log('content', content);
editor = new Editor({
element: element,
extensions: [
@ -198,32 +204,44 @@
// force re-render so `editor.isActive` works as expected
editor = editor;
if (!raw) {
let newValue = turndownService
.turndown(
editor
.getHTML()
.replace(/<p><\/p>/g, '<br/>')
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' ');
html = editor.getHTML();
if (!preserveBreaks) {
newValue = newValue.replace(/<br\/>/g, '');
}
onChange({
html: editor.getHTML(),
json: editor.getJSON(),
md: turndownService.turndown(editor.getHTML())
});
if (value !== newValue) {
value = newValue;
if (json) {
value = editor.getJSON();
} else {
if (!raw) {
let newValue = turndownService
.turndown(
editor
.getHTML()
.replace(/<p><\/p>/g, '<br/>')
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' ');
// check if the node is paragraph as well
if (editor.isActive('paragraph')) {
if (value === '') {
editor.commands.clearContent();
if (!preserveBreaks) {
newValue = newValue.replace(/<br\/>/g, '');
}
if (value !== newValue) {
value = newValue;
// check if the node is paragraph as well
if (editor.isActive('paragraph')) {
if (value === '') {
editor.commands.clearContent();
}
}
}
} else {
value = editor.getHTML();
}
} else {
value = editor.getHTML();
}
},
editorProps: {
@ -356,35 +374,25 @@
}
});
// Update the editor content if the external `value` changes
$: if (
editor &&
(raw
? value !== editor.getHTML()
: value !==
turndownService
.turndown(
(preserveBreaks
? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
: editor.getHTML()
).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' '))
) {
if (raw) {
editor.commands.setContent(value);
} else {
preserveBreaks
? editor.commands.setContent(value)
: editor.commands.setContent(
marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false
})
); // Update editor content
}
selectTemplate();
$: if (value && editor) {
onValueChange();
}
const onValueChange = () => {
if (!editor) return;
if (json) {
if (JSON.stringify(value) !== JSON.stringify(editor.getJSON())) {
editor.commands.setContent(value);
selectTemplate();
}
} else {
if (value !== editor.getHTML()) {
editor.commands.setContent(value);
selectTemplate();
}
}
};
</script>
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-3';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
/>
</svg>

View File

@ -0,0 +1,11 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
<path
fill-rule="evenodd"
d="M6.75 2.25A.75.75 0 0 1 7.5 3v1.5h9V3A.75.75 0 0 1 18 3v1.5h.75a3 3 0 0 1 3 3v11.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V7.5a3 3 0 0 1 3-3H6V3a.75.75 0 0 1 .75-.75Zm13.5 9a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5Z"
clip-rule="evenodd"
/>
</svg>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-3';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
/>
</svg>

View File

@ -38,6 +38,7 @@
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
export let className = '';
@ -65,6 +66,7 @@
let showShareChatModal = false;
let confirmEdit = false;
let editInputFocused = false;
let chatTitle = title;
@ -133,10 +135,6 @@
dispatch('change');
};
const focusEdit = async (node: HTMLInputElement) => {
node.focus();
};
let itemElement;
let dragged = false;
@ -213,6 +211,20 @@
chatTitle = '';
}
};
const focusEditInput = async () => {
console.log('focusEditInput');
await tick();
const input = document.getElementById(`chat-title-input-${id}`);
if (input) {
input.focus();
}
};
$: if (confirmEdit) {
focusEditInput();
}
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
@ -257,11 +269,23 @@
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
>
<input
use:focusEdit
bind:value={chatTitle}
id="chat-title-input-{id}"
bind:value={chatTitle}
class=" bg-transparent w-full outline-hidden mr-10"
on:keydown={chatTitleInputKeydownHandler}
on:focus={() => {
editInputFocused = true;
}}
on:blur={() => {
if (editInputFocused) {
if (chatTitle !== title) {
editChatTitle(id, chatTitle);
}
confirmEdit = false;
chatTitle = '';
}
}}
/>
</div>
{:else}
@ -311,7 +335,7 @@
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute {className === 'pr-2'
? 'right-[8px]'
: 'right-0'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
to-transparent"
on:mouseenter={(e) => {
@ -325,28 +349,9 @@
<div
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
>
<Tooltip content={$i18n.t('Confirm')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
editChatTitle(id, chatTitle);
confirmEdit = false;
chatTitle = '';
}}
>
<Check className=" size-3.5" strokeWidth="2.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Cancel')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
confirmEdit = false;
chatTitle = '';
}}
>
<XMark strokeWidth="2.5" />
<Tooltip content={$i18n.t('Generate')}>
<button class=" self-center dark:hover:text-white transition" on:click={() => {}}>
<Sparkles strokeWidth="2" />
</button>
</Tooltip>
</div>
@ -377,7 +382,7 @@
</Tooltip>
</div>
{:else}
<div class="flex self-center space-x-1 z-10">
<div class="flex self-center z-10 items-end">
<ChatMenu
chatId={id}
cloneChatHandler={() => {
@ -414,7 +419,7 @@
>
<button
aria-label="Chat Menu"
class=" self-center dark:hover:text-white transition"
class=" self-center dark:hover:text-white transition m-0"
on:click={() => {
dispatch('select');
}}

View File

@ -0,0 +1,220 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { toast } from 'svelte-sonner';
import { showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
import dayjs from '$lib/dayjs';
import calendar from 'dayjs/plugin/calendar';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(calendar);
dayjs.extend(duration);
dayjs.extend(relativeTime);
async function loadLocale(locales) {
for (const locale of locales) {
try {
dayjs.locale(locale);
break; // Stop after successfully loading the first available locale
} catch (error) {
console.error(`Could not load locale '${locale}':`, error);
}
}
}
// Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages);
import { getNoteById, updateNoteById } from '$lib/apis/notes';
import RichTextInput from '../common/RichTextInput.svelte';
import Spinner from '../common/Spinner.svelte';
import Mic from '../icons/Mic.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Calendar from '../icons/Calendar.svelte';
import Users from '../icons/Users.svelte';
export let id: null | string = null;
let note = {
title: '',
data: {
content: {
json: null,
html: '',
md: ''
}
},
meta: null,
access_control: null
};
let voiceInput = false;
let loading = false;
const init = async () => {
loading = true;
const res = await getNoteById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
note = res;
} else {
toast.error($i18n.t('Note not found'));
goto('/notes');
return;
}
loading = false;
};
let debounceTimeout: NodeJS.Timeout | null = null;
const changeDebounceHandler = () => {
console.log('debounce');
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(async () => {
const res = await updateNoteById(localStorage.token, id, {
...note,
title: note.title === '' ? $i18n.t('Untitled') : note.title
}).catch((e) => {
toast.error(`${e}`);
});
}, 200);
};
$: if (note) {
changeDebounceHandler();
}
$: if (id) {
init();
}
</script>
<div class="relative flex-1 w-full h-full flex justify-center">
{#if loading}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto">
<Spinner />
</div>
</div>
{:else}
<div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}">
<div class="shrink-0 w-full flex justify-between items-center px-4.5 mb-1">
<div class="w-full">
<input
class="w-full text-2xl font-medium bg-transparent outline-hidden"
type="text"
bind:value={note.title}
placeholder={$i18n.t('Title')}
required
/>
</div>
</div>
<div
class="flex gap-1 px-3.5 items-center text-xs font-medium text-gray-500 dark:text-gray-500 mb-4"
>
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
<Calendar className="size-3.5" strokeWidth="2" />
<span>{dayjs(note.created_at / 1000000).calendar()}</span>
</button>
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
<Users className="size-3.5" strokeWidth="2" />
<span> You </span>
</button>
</div>
<div class=" flex-1 w-full h-full overflow-auto px-4.5 pb-5">
<RichTextInput
className="input-prose-sm"
bind:value={note.data.content.json}
placeholder={$i18n.t('Write something...')}
json={true}
onChange={(content) => {
note.data.html = content.html;
note.data.md = content.md;
}}
/>
</div>
</div>
{/if}
</div>
<div class="absolute bottom-0 right-0 p-5 max-w-full flex justify-end">
<div
class="flex gap-0.5 justify-end w-full {$showSidebar && voiceInput
? 'md:max-w-[calc(100%-260px)]'
: ''} max-w-full"
>
{#if voiceInput}
<div class="flex-1 w-full">
<VoiceRecording
bind:recording={voiceInput}
className="p-1 w-full max-w-full"
transcribe={false}
onCancel={() => {
voiceInput = false;
}}
onConfirm={(data) => {
console.log(data);
}}
/>
</div>
{:else}
<Tooltip content={$i18n.t('Record')}>
<button
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
error: err
})
);
return null;
});
if (stream) {
voiceInput = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
>
<Mic className="size-4.5" />
</button>
</Tooltip>
{/if}
<!-- <button
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
>
<SparklesSolid className="size-4" />
</button> -->
</div>
</div>

View File

@ -0,0 +1,250 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import dayjs from '$lib/dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
dayjs.extend(relativeTime);
async function loadLocale(locales) {
for (const locale of locales) {
try {
dayjs.locale(locale);
break; // Stop after successfully loading the first available locale
} catch (error) {
console.error(`Could not load locale '${locale}':`, error);
}
}
}
// Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages);
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import { createNewNote, getNotes } from '$lib/apis/notes';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Spinner from '../common/Spinner.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { capitalizeFirstLetter } from '$lib/utils';
const i18n = getContext('i18n');
let loaded = false;
let importFiles = '';
let query = '';
let notes = [];
let selectedNote = null;
let showDeleteConfirm = false;
const init = async () => {
notes = await getNotes(localStorage.token);
};
const createNoteHandler = async () => {
const res = await createNewNote(localStorage.token, {
title: $i18n.t('New Note'),
data: {
content: {
json: null,
html: '',
md: ''
}
},
meta: null,
access_control: null
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
goto(`/notes/${res.id}`);
}
};
onMount(async () => {
await init();
loaded = true;
});
</script>
<svelte:head>
<title>
{$i18n.t('Notes')}{$WEBUI_NAME}
</title>
</svelte:head>
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete note?')}
on:confirm={() => {}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>.
</div>
</DeleteConfirmDialog>
<div class="px-4.5 @container h-full">
{#if Object.keys(notes).length > 0}
{#each Object.keys(notes) as timeRange}
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2">
{$i18n.t(timeRange)}
</div>
<div class="mb-5 gap-2 grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-4">
{#each notes[timeRange] as note, idx (note.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 bg-gray-50 dark:bg-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a
href={`/notes/${note.id}`}
class="w-full -translate-y-0.5 flex flex-col justify-between"
>
<div class="flex-1">
<div class=" flex items-center gap-2 self-center mb-1">
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
</div>
<div
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18"
>
{#if note.data?.md}
{note.data?.md}
{:else}
{$i18n.t('No content')}
{/if}
</div>
</div>
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
<div>
{dayjs(note.updated_at / 1000000).fromNow()}
</div>
<Tooltip
content={note?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
</div>
</a>
</div>
</div>
{/each}
</div>
{/each}
{:else}
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="pb-20 text-center">
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')}
</div>
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
{$i18n.t('Create your first note by clicking on the plus button below.')}
</div>
</div>
</div>
{/if}
</div>
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
<div class="flex gap-0.5 justify-end w-full">
<Tooltip content={$i18n.t('Create Note')}>
<button
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
type="button"
on:click={async () => {
createNoteHandler();
}}
>
<Plus className="size-4.5" strokeWidth="2.5" />
</button>
</Tooltip>
<!-- <button
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
>
<SparklesSolid className="size-4" />
</button> -->
</div>
</div>
<!-- {#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-2">
<input
id="notes-import-input"
bind:files={importFiles}
type="file"
accept=".md"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
console.log(event.target.result);
};
reader.readAsText(importFiles[0]);
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
const notesImportInputElement = document.getElementById('notes-import-input');
if (notesImportInputElement) {
notesImportInputElement.click();
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
{/if} -->
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}

View File

@ -57,11 +57,11 @@
<VoiceRecording
bind:recording={voiceInput}
className="p-1 w-full max-w-full"
on:cancel={() => {
onCancel={() => {
voiceInput = false;
}}
on:confirm={(e) => {
const { text, filename } = e.detail;
onConfirm={(data) => {
const { text, filename } = data;
// url is hostname + /cache/audio/transcription/ + filename
const url = `${window.location.origin}/cache/audio/transcription/${filename}`;

View File

@ -72,7 +72,7 @@
<svelte:head>
<title>
{$i18n.t('Knowledge')} | {$WEBUI_NAME}
{$i18n.t('Knowledge')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@ -85,11 +85,11 @@
<VoiceRecording
bind:recording={voiceInput}
className="p-1"
on:cancel={() => {
onCancel={() => {
voiceInput = false;
}}
on:confirm={(e) => {
const { text, filename } = e.detail;
onConfirm={(data) => {
const { text, filename } = data;
content = `${content}${text} `;
voiceInput = false;

View File

@ -196,7 +196,7 @@
<svelte:head>
<title>
{$i18n.t('Models')} | {$WEBUI_NAME}
{$i18n.t('Models')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@ -88,7 +88,7 @@
<svelte:head>
<title>
{$i18n.t('Prompts')} | {$WEBUI_NAME}
{$i18n.t('Prompts')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@ -164,7 +164,7 @@
<svelte:head>
<title>
{$i18n.t('Tools')} | {$WEBUI_NAME}
{$i18n.t('Tools')} {$WEBUI_NAME}
</title>
</svelte:head>

View File

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, showSidebar, functions, config } from '$lib/stores';
import { WEBUI_NAME, showSidebar, functions, config, user, showArchivedChats } from '$lib/stores';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
import { page } from '$app/stores';
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
const i18n = getContext('i18n');
@ -25,9 +26,9 @@
? 'md:max-w-[calc(100%-260px)]'
: ''} max-w-full"
>
<nav class=" px-2.5 pt-1 backdrop-blur-xl w-full drag-region">
<nav class=" px-2 pt-1 backdrop-blur-xl w-full drag-region">
<div class=" flex items-center">
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center self-end">
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<button
id="sidebar-toggle-button"
class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
@ -41,10 +42,50 @@
</div>
</button>
</div>
<div class="ml-2 py-0.5 self-center flex items-center justify-between w-full">
<div class="">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium bg-transparent py-1 touch-auto pointer-events-auto"
>
<a class="min-w-fit transition" href="/notes">
{$i18n.t('Notes')}
</a>
</div>
</div>
<div class=" self-center flex items-center gap-1">
{#if $user !== undefined && $user !== null}
<UserMenu
className="max-w-[200px]"
role={$user?.role}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={$user?.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</nav>
<div class=" flex-1 max-h-full overflow-y-auto">
<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container">
<slot />
</div>
</div>

View File

@ -0,0 +1,5 @@
<script>
import Notes from '$lib/components/notes/Notes.svelte';
</script>
<Notes />

View File

@ -0,0 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import NoteEditor from '$lib/components/notes/NoteEditor.svelte';
</script>
<NoteEditor id={$page.params.id} />