فهرست منبع

Merge pull request #13459 from open-webui/notes

feat: notes
Tim Jaeryang Baek 5 ماه پیش
والد
کامیت
1af45435eb
28فایلهای تغییر یافته به همراه1329 افزوده شده و 211 حذف شده
  1. 3 0
      backend/open_webui/main.py
  2. 33 0
      backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py
  3. 135 0
      backend/open_webui/models/notes.py
  4. 159 0
      backend/open_webui/routers/notes.py
  5. 187 0
      src/lib/apis/notes/index.ts
  6. 1 1
      src/lib/components/admin/Functions.svelte
  7. 1 1
      src/lib/components/channel/Channel.svelte
  8. 3 3
      src/lib/components/channel/MessageInput.svelte
  9. 1 1
      src/lib/components/chat/Chat.svelte
  10. 3 3
      src/lib/components/chat/MessageInput.svelte
  11. 95 78
      src/lib/components/chat/MessageInput/VoiceRecording.svelte
  12. 2 2
      src/lib/components/chat/Navbar.svelte
  13. 85 77
      src/lib/components/common/RichTextInput.svelte
  14. 19 0
      src/lib/components/icons/Calendar.svelte
  15. 11 0
      src/lib/components/icons/CalendarSolid.svelte
  16. 19 0
      src/lib/components/icons/Users.svelte
  17. 36 31
      src/lib/components/layout/Sidebar/ChatItem.svelte
  18. 220 0
      src/lib/components/notes/NoteEditor.svelte
  19. 250 0
      src/lib/components/notes/Notes.svelte
  20. 3 3
      src/lib/components/playground/Notes.svelte
  21. 1 1
      src/lib/components/workspace/Knowledge.svelte
  22. 3 3
      src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte
  23. 1 1
      src/lib/components/workspace/Models.svelte
  24. 1 1
      src/lib/components/workspace/Prompts.svelte
  25. 1 1
      src/lib/components/workspace/Tools.svelte
  26. 45 4
      src/routes/(app)/notes/+layout.svelte
  27. 5 0
      src/routes/(app)/notes/+page.svelte
  28. 6 0
      src/routes/(app)/notes/[id]/+page.svelte

+ 3 - 0
backend/open_webui/main.py

@@ -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"])

+ 33 - 0
backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py

@@ -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")

+ 135 - 0
backend/open_webui/models/notes.py

@@ -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()

+ 159 - 0
backend/open_webui/routers/notes.py

@@ -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 - 0
src/lib/apis/notes/index.ts

@@ -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;
+};

+ 1 - 1
src/lib/components/admin/Functions.svelte

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

+ 1 - 1
src/lib/components/channel/Channel.svelte

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

+ 3 - 3
src/lib/components/channel/MessageInput.svelte

@@ -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;
 

+ 1 - 1
src/lib/components/chat/Chat.svelte

@@ -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>

+ 3 - 3
src/lib/components/chat/MessageInput.svelte

@@ -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;

+ 95 - 78
src/lib/components/chat/MessageInput/VoiceRecording.svelte

@@ -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;
+			}
+
+			const res = await transcribeAudio(localStorage.token, file).catch((error) => {
+				toast.error(`${error}`);
+				return null;
+			});
 
-		if (res) {
-			console.log(res);
-			dispatch('confirm', res);
+			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;
-
-				// Set the timeout for turning off the recognition after inactivity (in milliseconds)
-				const inactivityTimeout = 2000; // 3 seconds
-
-				let timeoutId;
-				// Start recognition
-				speechRecognition.start();
-
-				// Event triggered when speech is recognized
-				speechRecognition.onresult = async (event) => {
-					// Clear the inactivity timeout
-					clearTimeout(timeoutId);
-
-					// Handle recognized speech
-					console.log(event);
-					const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
-
-					transcription = `${transcription}${transcript}`;
-
-					await tick();
-					document.getElementById('chat-input')?.focus();
-
-					// Restart the inactivity timeout
-					timeoutId = setTimeout(() => {
-						console.log('Speech recognition turned off due to inactivity.');
-						speechRecognition.stop();
-					}, inactivityTimeout);
-				};
-
-				// Event triggered when recognition is ended
-				speechRecognition.onend = function () {
-					// Restart recognition after it ends
-					console.log('recognition ended');
-
-					confirmRecording();
-					dispatch('confirm', { text: transcription });
-					confirmed = false;
-					loading = false;
-				};
-
-				// 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');
-
-					stopRecording();
-				};
+
+		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 continuous to true for continuous recognition
+					speechRecognition.continuous = true;
+
+					// Set the timeout for turning off the recognition after inactivity (in milliseconds)
+					const inactivityTimeout = 2000; // 3 seconds
+
+					let timeoutId;
+					// Start recognition
+					speechRecognition.start();
+
+					// Event triggered when speech is recognized
+					speechRecognition.onresult = async (event) => {
+						// Clear the inactivity timeout
+						clearTimeout(timeoutId);
+
+						// Handle recognized speech
+						console.log(event);
+						const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
+
+						transcription = `${transcription}${transcript}`;
+
+						await tick();
+						document.getElementById('chat-input')?.focus();
+
+						// Restart the inactivity timeout
+						timeoutId = setTimeout(() => {
+							console.log('Speech recognition turned off due to inactivity.');
+							speechRecognition.stop();
+						}, inactivityTimeout);
+					};
+
+					// Event triggered when recognition is ended
+					speechRecognition.onend = function () {
+						// Restart recognition after it ends
+						console.log('recognition ended');
+
+						confirmRecording();
+						onConfirm({
+							text: transcription
+						});
+						confirmed = false;
+						loading = false;
+					};
+
+					// 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

+ 2 - 2
src/lib/components/chat/Navbar.svelte

@@ -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>

+ 85 - 77
src/lib/components/common/RichTextInput.svelte

@@ -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 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, ' ');
-
-					if (!preserveBreaks) {
-						newValue = newValue.replace(/<br\/>/g, '');
-					}
+				html = editor.getHTML();
 
-					if (value !== newValue) {
-						value = newValue;
+				onChange({
+					html: editor.getHTML(),
+					json: editor.getJSON(),
+					md: turndownService.turndown(editor.getHTML())
+				});
 
-						// check if the node is paragraph as well
-						if (editor.isActive('paragraph')) {
-							if (value === '') {
-								editor.commands.clearContent();
+				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, ' ');
+
+						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);
+	$: 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 {
-			preserveBreaks
-				? editor.commands.setContent(value)
-				: editor.commands.setContent(
-						marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
-							breaks: false
-						})
-					); // Update editor content
+			if (value !== editor.getHTML()) {
+				editor.commands.setContent(value);
+				selectTemplate();
+			}
 		}
-
-		selectTemplate();
-	}
+	};
 </script>
 
 <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />

+ 19 - 0
src/lib/components/icons/Calendar.svelte

@@ -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>

+ 11 - 0
src/lib/components/icons/CalendarSolid.svelte

@@ -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>

+ 19 - 0
src/lib/components/icons/Users.svelte

@@ -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>

+ 36 - 31
src/lib/components/layout/Sidebar/ChatItem.svelte

@@ -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');
 						}}

+ 220 - 0
src/lib/components/notes/NoteEditor.svelte

@@ -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>

+ 250 - 0
src/lib/components/notes/Notes.svelte

@@ -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}

+ 3 - 3
src/lib/components/playground/Notes.svelte

@@ -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}`;

+ 1 - 1
src/lib/components/workspace/Knowledge.svelte

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

+ 3 - 3
src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte

@@ -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;

+ 1 - 1
src/lib/components/workspace/Models.svelte

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

+ 1 - 1
src/lib/components/workspace/Prompts.svelte

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

+ 1 - 1
src/lib/components/workspace/Tools.svelte

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

+ 45 - 4
src/routes/(app)/notes/+layout.svelte

@@ -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>

+ 5 - 0
src/routes/(app)/notes/+page.svelte

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

+ 6 - 0
src/routes/(app)/notes/[id]/+page.svelte

@@ -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} />