diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 83f5e6f15..db124bedd 100644 --- a/backend/open_webui/main.py +++ b/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"]) diff --git a/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py new file mode 100644 index 000000000..8e983a2cf --- /dev/null +++ b/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") diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py new file mode 100644 index 000000000..114ccdc57 --- /dev/null +++ b/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() diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py new file mode 100644 index 000000000..a78953bb4 --- /dev/null +++ b/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() + ) diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts new file mode 100644 index 000000000..23bec36f2 --- /dev/null +++ b/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 = {}; + 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; +}; diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 87f4958d4..fbeb61596 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -192,7 +192,7 @@ - {$i18n.t('Functions')} | {$WEBUI_NAME} + {$i18n.t('Functions')} • {$WEBUI_NAME} diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index a0de69570..1b1ca25de 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -195,7 +195,7 @@ - #{channel?.name ?? 'Channel'} | Open WebUI + #{channel?.name ?? 'Channel'} • Open WebUI
{ + 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; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index b0f806452..283c8501d 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1921,7 +1921,7 @@ {$chatTitle - ? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}` + ? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}` : `${$WEBUI_NAME}`} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index d31861459..bcf28f61c 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -479,14 +479,14 @@ {#if recording} { + 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; diff --git a/src/lib/components/chat/MessageInput/VoiceRecording.svelte b/src/lib/components/chat/MessageInput/VoiceRecording.svelte index 70472055f..726e26bbb 100644 --- a/src/lib/components/chat/MessageInput/VoiceRecording.svelte +++ b/src/lib/components/chat/MessageInput/VoiceRecording.svelte @@ -1,6 +1,6 @@
diff --git a/src/lib/components/icons/Calendar.svelte b/src/lib/components/icons/Calendar.svelte new file mode 100644 index 000000000..4406015fd --- /dev/null +++ b/src/lib/components/icons/Calendar.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/lib/components/icons/CalendarSolid.svelte b/src/lib/components/icons/CalendarSolid.svelte new file mode 100644 index 000000000..3f75fc91b --- /dev/null +++ b/src/lib/components/icons/CalendarSolid.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/src/lib/components/icons/Users.svelte b/src/lib/components/icons/Users.svelte new file mode 100644 index 000000000..9ef032dd7 --- /dev/null +++ b/src/lib/components/icons/Users.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 96631e125..bc3b56b30 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/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(); + } @@ -257,11 +269,23 @@ : 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis" > { + editInputFocused = true; + }} + on:blur={() => { + if (editInputFocused) { + if (chatTitle !== title) { + editChatTitle(id, chatTitle); + } + + confirmEdit = false; + chatTitle = ''; + } + }} />
{: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 @@
- - - - - -
@@ -377,7 +382,7 @@
{:else} -
+
{ @@ -414,7 +419,7 @@ > + + +
+ +
+ { + note.data.html = content.html; + note.data.md = content.md; + }} + /> +
+
+ {/if} + + +
+
+ {#if voiceInput} +
+ { + voiceInput = false; + }} + onConfirm={(data) => { + console.log(data); + }} + /> +
+ {:else} + + + + {/if} + + +
+
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte new file mode 100644 index 000000000..fd8ff6b1b --- /dev/null +++ b/src/lib/components/notes/Notes.svelte @@ -0,0 +1,250 @@ + + + + + {$i18n.t('Notes')} • {$WEBUI_NAME} + + + +{#if loaded} + {}} + > +
+ {$i18n.t('This will delete')} {selectedNote.title}. +
+
+ +
+ {#if Object.keys(notes).length > 0} + {#each Object.keys(notes) as timeRange} +
+ {$i18n.t(timeRange)} +
+ + + {/each} + {:else} +
+
+
+ {$i18n.t('No Notes')} +
+ +
+ {$i18n.t('Create your first note by clicking on the plus button below.')} +
+
+
+ {/if} +
+ +
+
+ + + + + +
+
+ + +{:else} +
+ +
+{/if} diff --git a/src/lib/components/playground/Notes.svelte b/src/lib/components/playground/Notes.svelte index 8e41e2cde..8db333e69 100644 --- a/src/lib/components/playground/Notes.svelte +++ b/src/lib/components/playground/Notes.svelte @@ -57,11 +57,11 @@ { + 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}`; diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index 57d45312d..48fef678e 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -72,7 +72,7 @@ - {$i18n.t('Knowledge')} | {$WEBUI_NAME} + {$i18n.t('Knowledge')} • {$WEBUI_NAME} diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte index 3ada1c192..4762d5d97 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte @@ -85,11 +85,11 @@ { + onCancel={() => { voiceInput = false; }} - on:confirm={(e) => { - const { text, filename } = e.detail; + onConfirm={(data) => { + const { text, filename } = data; content = `${content}${text} `; voiceInput = false; diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index c9137fb85..5d1c47abf 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -196,7 +196,7 @@ - {$i18n.t('Models')} | {$WEBUI_NAME} + {$i18n.t('Models')} • {$WEBUI_NAME} diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 0c16b49a2..bc3f04073 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -88,7 +88,7 @@ - {$i18n.t('Prompts')} | {$WEBUI_NAME} + {$i18n.t('Prompts')} • {$WEBUI_NAME} diff --git a/src/lib/components/workspace/Tools.svelte b/src/lib/components/workspace/Tools.svelte index 726a39205..a604b5e79 100644 --- a/src/lib/components/workspace/Tools.svelte +++ b/src/lib/components/workspace/Tools.svelte @@ -164,7 +164,7 @@ - {$i18n.t('Tools')} | {$WEBUI_NAME} + {$i18n.t('Tools')} • {$WEBUI_NAME} diff --git a/src/routes/(app)/notes/+layout.svelte b/src/routes/(app)/notes/+layout.svelte index 3f6830240..8b8792c94 100644 --- a/src/routes/(app)/notes/+layout.svelte +++ b/src/routes/(app)/notes/+layout.svelte @@ -1,8 +1,9 @@ + + diff --git a/src/routes/(app)/notes/[id]/+page.svelte b/src/routes/(app)/notes/[id]/+page.svelte new file mode 100644 index 000000000..737c0e00f --- /dev/null +++ b/src/routes/(app)/notes/[id]/+page.svelte @@ -0,0 +1,6 @@ + + +