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}
+
+ {/if}
+
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}