diff --git a/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py new file mode 100644 index 0000000000..0d80558746 --- /dev/null +++ b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py @@ -0,0 +1,23 @@ +"""Add is_pinned to note table + +Revision ID: e1f2a3b4c5d6 +Revises: b7c8d9e0f1a2 +Create Date: 2026-04-14 22:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'e1f2a3b4c5d6' +down_revision = 'b7c8d9e0f1a2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('note', 'is_pinned') diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 25a7905800..1a34750a7d 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -4,7 +4,7 @@ import uuid from typing import Optional from functools import lru_cache -from sqlalchemy import select, delete, update, or_, func, cast +from sqlalchemy import Boolean, select, delete, update, or_, func, cast from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import Base, get_async_db_context from open_webui.models.groups import Groups @@ -29,6 +29,7 @@ class Note(Base): title = Column(Text) data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) + is_pinned = Column(Boolean, default=False, nullable=True) created_at = Column(BigInteger) updated_at = Column(BigInteger) @@ -43,6 +44,7 @@ class NoteModel(BaseModel): title: str data: Optional[dict] = None meta: Optional[dict] = None + is_pinned: Optional[bool] = False access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -77,6 +79,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -311,6 +314,39 @@ class NoteTable: await db.commit() return await self._to_note_model(note, db=db) if note else None + async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]: + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Note).filter(Note.id == id)) + note = result.scalars().first() + if not note: + return None + note.is_pinned = not note.is_pinned + note.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_note_model(note, db=db) + except Exception: + return None + + async def get_pinned_notes_by_user_id( + self, + user_id: str, + permission: str = 'read', + db: Optional[AsyncSession] = None, + ) -> list[NoteModel]: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc()) + stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission) + + result = await db.execute(stmt) + notes = result.scalars().all() + note_ids = [note.id for note in notes] + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] + async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 61c9fb7d95..4fbdd09993 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -58,6 +58,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -104,6 +105,45 @@ async def get_notes( ] +############################ +# GetPinnedNotes +############################ + + +@router.get('/pinned', response_model=list[NoteItemResponse]) +async def get_pinned_notes( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = await Notes.get_pinned_notes_by_user_id(user.id, 'read', db=db) + if not notes: + return [] + + user_ids = list(set(note.user_id for note in notes)) + users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} + + return [ + NoteUserResponse( + **{ + **note.model_dump(), + 'data': _truncate_note_data(note.data), + 'user': UserResponse(**users[note.user_id].model_dump()), + } + ) + for note in notes + if note.user_id in users + ] + + @router.get('/search', response_model=NoteListResponse) async def search_notes( request: Request, @@ -364,6 +404,46 @@ async def update_note_access_by_id( return await Notes.get_note_by_id(id, db=db) +############################ +# PinNoteById +############################ + + +@router.post('/{id}/pin', response_model=Optional[NoteModel]) +async def pin_note_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + note = await Notes.toggle_note_pinned_by_id(id, db=db) + return note + + ############################ # DeleteNoteById ############################ diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 07e249a889..c7253871a4 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -313,3 +313,66 @@ export const deleteNoteById = async (token: string, id: string) => { return res; }; + +export const getPinnedNoteList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/pinned`, { + 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.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res ?? []; +}; + +export const toggleNotePinnedStatusById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pin`, { + method: 'POST', + 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.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index e28229989e..dcd1c7cc78 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -16,6 +16,7 @@ mobile, showArchivedChats, pinnedChats, + pinnedNotes, scrollPaginationEnabled, currentChatPage, temporaryChatEnabled, @@ -44,6 +45,7 @@ } from '$lib/apis/chats'; import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders'; import { checkActiveChats } from '$lib/apis/tasks'; + import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import ArchivedChatsModal from './ArchivedChatsModal.svelte'; @@ -86,6 +88,7 @@ let pinnedModels = []; let showPinnedModels = false; + let showPinnedNotes = false; let showChannels = false; let showFolders = false; @@ -227,6 +230,13 @@ const _pinnedChats = await getPinnedChatList(localStorage.token); pinnedChats.set(_pinnedChats); })(), + await (async () => { + if ($config?.features?.enable_notes && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))) { + console.log('Init pinned notes'); + const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []); + pinnedNotes.set(_pinnedNotes); + } + })(), await (async () => { console.log('Init chat list'); const _chats = await getChatList(localStorage.token, $currentChatPage); @@ -1072,6 +1082,50 @@ {/if} + {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0} + +
+ {#each $pinnedNotes as note (note.id)} + { + itemClickHandler(); + }} + draggable="false" + > +
+ +
+
+ {note.title} +
+ +
+ {/each} +
+
+ {/if} + {#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))} { showDeleteConfirm = true; }} + isPinned={note.is_pinned ?? false} + onPin={async () => { + await toggleNotePinnedStatusById(localStorage.token, note.id); + note = await getNoteById(localStorage.token, note.id); + pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); + }} >
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 4b4a208a83..e166fb308a 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -30,13 +30,15 @@ $: loadLocale($i18n.languages); import { goto } from '$app/navigation'; - import { WEBUI_NAME, config, user } from '$lib/stores'; + import { WEBUI_NAME, config, user, pinnedNotes } from '$lib/stores'; import { createNewNote, deleteNoteById, getNoteById, getNoteList, - searchNotes + searchNotes, + toggleNotePinnedStatusById, + getPinnedNoteList } from '$lib/apis/notes'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; import { downloadPdf, createNoteHandler } from './utils'; @@ -540,6 +542,12 @@ selectedNote = note; showDeleteConfirm = true; }} + isPinned={note.is_pinned ?? false} + onPin={async () => { + await toggleNotePinnedStatusById(localStorage.token, note.id); + pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); + init(); + }} > + {/if} + +