From 7fee84c06e665b895fc1193f3d5f06c2edbff400 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 3 May 2025 18:16:32 +0400 Subject: [PATCH] feat: notes --- backend/open_webui/main.py | 3 + .../versions/9f0c9cd09105_add_note_table.py | 33 +++ backend/open_webui/models/notes.py | 135 ++++++++++++ backend/open_webui/routers/notes.py | 163 ++++++++++++++ src/lib/apis/notes/index.ts | 168 +++++++++++++++ src/lib/components/admin/Functions.svelte | 2 +- src/lib/components/channel/Channel.svelte | 2 +- src/lib/components/chat/Chat.svelte | 2 +- src/lib/components/chat/Navbar.svelte | 4 +- .../components/layout/Sidebar/ChatItem.svelte | 67 +++--- src/lib/components/notes/Notes.svelte | 204 ++++++++++++++++++ src/lib/components/workspace/Knowledge.svelte | 2 +- src/lib/components/workspace/Models.svelte | 2 +- src/lib/components/workspace/Prompts.svelte | 2 +- src/lib/components/workspace/Tools.svelte | 2 +- src/routes/(app)/notes/+layout.svelte | 49 ++++- src/routes/(app)/notes/+page.svelte | 5 + 17 files changed, 801 insertions(+), 44 deletions(-) create mode 100644 backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py create mode 100644 backend/open_webui/models/notes.py create mode 100644 backend/open_webui/routers/notes.py create mode 100644 src/lib/apis/notes/index.ts create mode 100644 src/lib/components/notes/Notes.svelte diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index ef38904c0..f19f81022 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, @@ -996,6 +997,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..ed373e1b5 --- /dev/null +++ b/backend/open_webui/routers/notes.py @@ -0,0 +1,163 @@ +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, UserNameResponse +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": UserNameResponse( + **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": UserNameResponse( + **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..445697fd3 --- /dev/null +++ b/src/lib/apis/notes/index.ts @@ -0,0 +1,168 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +type NoteItem = { + title: string; + content: string; + 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; + } + + return res; +}; + +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
{$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/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index 4fbc8029e..4687dd983 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -47,8 +47,8 @@ -