Skip to content

Commit ecd74f2

Browse files
committed
refac
1 parent 8bd23b9 commit ecd74f2

9 files changed

Lines changed: 307 additions & 5 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Add is_pinned to note table
2+
3+
Revision ID: e1f2a3b4c5d6
4+
Revises: b7c8d9e0f1a2
5+
Create Date: 2026-04-14 22:00:00.000000
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
revision = 'e1f2a3b4c5d6'
13+
down_revision = 'b7c8d9e0f1a2'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
20+
21+
22+
def downgrade():
23+
op.drop_column('note', 'is_pinned')

backend/open_webui/models/notes.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Optional
55
from functools import lru_cache
66

7-
from sqlalchemy import select, delete, update, or_, func, cast
7+
from sqlalchemy import Boolean, select, delete, update, or_, func, cast
88
from sqlalchemy.ext.asyncio import AsyncSession
99
from open_webui.internal.db import Base, get_async_db_context
1010
from open_webui.models.groups import Groups
@@ -29,6 +29,7 @@ class Note(Base):
2929
title = Column(Text)
3030
data = Column(JSON, nullable=True)
3131
meta = Column(JSON, nullable=True)
32+
is_pinned = Column(Boolean, default=False, nullable=True)
3233

3334
created_at = Column(BigInteger)
3435
updated_at = Column(BigInteger)
@@ -43,6 +44,7 @@ class NoteModel(BaseModel):
4344
title: str
4445
data: Optional[dict] = None
4546
meta: Optional[dict] = None
47+
is_pinned: Optional[bool] = False
4648

4749
access_grants: list[AccessGrantModel] = Field(default_factory=list)
4850

@@ -77,6 +79,7 @@ class NoteItemResponse(BaseModel):
7779
id: str
7880
title: str
7981
data: Optional[dict]
82+
is_pinned: Optional[bool] = False
8083
updated_at: int
8184
created_at: int
8285
user: Optional[UserResponse] = None
@@ -311,6 +314,39 @@ async def update_note_by_id(
311314
await db.commit()
312315
return await self._to_note_model(note, db=db) if note else None
313316

317+
async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
318+
try:
319+
async with get_async_db_context(db) as db:
320+
result = await db.execute(select(Note).filter(Note.id == id))
321+
note = result.scalars().first()
322+
if not note:
323+
return None
324+
note.is_pinned = not note.is_pinned
325+
note.updated_at = int(time.time_ns())
326+
await db.commit()
327+
return await self._to_note_model(note, db=db)
328+
except Exception:
329+
return None
330+
331+
async def get_pinned_notes_by_user_id(
332+
self,
333+
user_id: str,
334+
permission: str = 'read',
335+
db: Optional[AsyncSession] = None,
336+
) -> list[NoteModel]:
337+
async with get_async_db_context(db) as db:
338+
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
339+
user_group_ids = [group.id for group in user_groups]
340+
341+
stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc())
342+
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
343+
344+
result = await db.execute(stmt)
345+
notes = result.scalars().all()
346+
note_ids = [note.id for note in notes]
347+
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
348+
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
349+
314350
async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
315351
try:
316352
async with get_async_db_context(db) as db:

backend/open_webui/routers/notes.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class NoteItemResponse(BaseModel):
5858
id: str
5959
title: str
6060
data: Optional[dict]
61+
is_pinned: Optional[bool] = False
6162
updated_at: int
6263
created_at: int
6364
user: Optional[UserResponse] = None
@@ -104,6 +105,45 @@ async def get_notes(
104105
]
105106

106107

108+
############################
109+
# GetPinnedNotes
110+
############################
111+
112+
113+
@router.get('/pinned', response_model=list[NoteItemResponse])
114+
async def get_pinned_notes(
115+
request: Request,
116+
user=Depends(get_verified_user),
117+
db: AsyncSession = Depends(get_async_session),
118+
):
119+
if user.role != 'admin' and not await has_permission(
120+
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
121+
):
122+
raise HTTPException(
123+
status_code=status.HTTP_401_UNAUTHORIZED,
124+
detail=ERROR_MESSAGES.UNAUTHORIZED,
125+
)
126+
127+
notes = await Notes.get_pinned_notes_by_user_id(user.id, 'read', db=db)
128+
if not notes:
129+
return []
130+
131+
user_ids = list(set(note.user_id for note in notes))
132+
users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)}
133+
134+
return [
135+
NoteUserResponse(
136+
**{
137+
**note.model_dump(),
138+
'data': _truncate_note_data(note.data),
139+
'user': UserResponse(**users[note.user_id].model_dump()),
140+
}
141+
)
142+
for note in notes
143+
if note.user_id in users
144+
]
145+
146+
107147
@router.get('/search', response_model=NoteListResponse)
108148
async def search_notes(
109149
request: Request,
@@ -364,6 +404,46 @@ async def update_note_access_by_id(
364404
return await Notes.get_note_by_id(id, db=db)
365405

366406

407+
############################
408+
# PinNoteById
409+
############################
410+
411+
412+
@router.post('/{id}/pin', response_model=Optional[NoteModel])
413+
async def pin_note_by_id(
414+
request: Request,
415+
id: str,
416+
user=Depends(get_verified_user),
417+
db: AsyncSession = Depends(get_async_session),
418+
):
419+
if user.role != 'admin' and not await has_permission(
420+
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
421+
):
422+
raise HTTPException(
423+
status_code=status.HTTP_401_UNAUTHORIZED,
424+
detail=ERROR_MESSAGES.UNAUTHORIZED,
425+
)
426+
427+
note = await Notes.get_note_by_id(id, db=db)
428+
if not note:
429+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
430+
431+
if user.role != 'admin' and (
432+
user.id != note.user_id
433+
and not await AccessGrants.has_access(
434+
user_id=user.id,
435+
resource_type='note',
436+
resource_id=note.id,
437+
permission='read',
438+
db=db,
439+
)
440+
):
441+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
442+
443+
note = await Notes.toggle_note_pinned_by_id(id, db=db)
444+
return note
445+
446+
367447
############################
368448
# DeleteNoteById
369449
############################

src/lib/apis/notes/index.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,66 @@ export const deleteNoteById = async (token: string, id: string) => {
313313

314314
return res;
315315
};
316+
317+
export const getPinnedNoteList = async (token: string = '') => {
318+
let error = null;
319+
320+
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/pinned`, {
321+
method: 'GET',
322+
headers: {
323+
Accept: 'application/json',
324+
'Content-Type': 'application/json',
325+
authorization: `Bearer ${token}`
326+
}
327+
})
328+
.then(async (res) => {
329+
if (!res.ok) throw await res.json();
330+
return res.json();
331+
})
332+
.then((json) => {
333+
return json;
334+
})
335+
.catch((err) => {
336+
error = err.detail;
337+
console.error(err);
338+
return null;
339+
});
340+
341+
if (error) {
342+
throw error;
343+
}
344+
345+
return res ?? [];
346+
};
347+
348+
export const toggleNotePinnedStatusById = async (token: string, id: string) => {
349+
let error = null;
350+
351+
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pin`, {
352+
method: 'POST',
353+
headers: {
354+
Accept: 'application/json',
355+
'Content-Type': 'application/json',
356+
authorization: `Bearer ${token}`
357+
}
358+
})
359+
.then(async (res) => {
360+
if (!res.ok) throw await res.json();
361+
return res.json();
362+
})
363+
.then((json) => {
364+
return json;
365+
})
366+
.catch((err) => {
367+
error = err.detail;
368+
console.error(err);
369+
return null;
370+
});
371+
372+
if (error) {
373+
throw error;
374+
}
375+
376+
return res;
377+
};
378+

src/lib/components/layout/Sidebar.svelte

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
mobile,
1717
showArchivedChats,
1818
pinnedChats,
19+
pinnedNotes,
1920
scrollPaginationEnabled,
2021
currentChatPage,
2122
temporaryChatEnabled,
@@ -44,6 +45,7 @@
4445
} from '$lib/apis/chats';
4546
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
4647
import { checkActiveChats } from '$lib/apis/tasks';
48+
import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
4749
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
4850
4951
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
@@ -86,6 +88,7 @@
8688
let pinnedModels = [];
8789
8890
let showPinnedModels = false;
91+
let showPinnedNotes = false;
8992
let showChannels = false;
9093
let showFolders = false;
9194
@@ -227,6 +230,13 @@
227230
const _pinnedChats = await getPinnedChatList(localStorage.token);
228231
pinnedChats.set(_pinnedChats);
229232
})(),
233+
await (async () => {
234+
if ($config?.features?.enable_notes && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))) {
235+
console.log('Init pinned notes');
236+
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
237+
pinnedNotes.set(_pinnedNotes);
238+
}
239+
})(),
230240
await (async () => {
231241
console.log('Init chat list');
232242
const _chats = await getChatList(localStorage.token, $currentChatPage);
@@ -1072,6 +1082,50 @@
10721082
</Folder>
10731083
{/if}
10741084

1085+
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
1086+
<Folder
1087+
id="sidebar-pinned-notes"
1088+
bind:open={showPinnedNotes}
1089+
className="px-2 mt-0.5"
1090+
name={$i18n.t('Notes')}
1091+
chevron={false}
1092+
dragAndDrop={false}
1093+
>
1094+
<div class="mt-0.5 pb-1.5">
1095+
{#each $pinnedNotes as note (note.id)}
1096+
<a
1097+
class="w-full flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition group text-sm"
1098+
href={`/notes/${note.id}`}
1099+
on:click={() => {
1100+
itemClickHandler();
1101+
}}
1102+
draggable="false"
1103+
>
1104+
<div class="self-center">
1105+
<Note className="size-4" strokeWidth="2" />
1106+
</div>
1107+
<div class="flex-1 text-ellipsis line-clamp-1">
1108+
{note.title}
1109+
</div>
1110+
<button
1111+
class="invisible group-hover:visible self-center p-0.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg transition"
1112+
on:click|preventDefault|stopPropagation={async () => {
1113+
await toggleNotePinnedStatusById(localStorage.token, note.id);
1114+
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
1115+
pinnedNotes.set(_pinnedNotes);
1116+
}}
1117+
aria-label={$i18n.t('Unpin')}
1118+
>
1119+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5">
1120+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
1121+
</svg>
1122+
</button>
1123+
</a>
1124+
{/each}
1125+
</div>
1126+
</Folder>
1127+
{/if}
1128+
10751129
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
10761130
<Folder
10771131
id="sidebar-channels"

src/lib/components/notes/NoteEditor.svelte

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
showSidebar,
3636
socket,
3737
user,
38-
WEBUI_NAME
38+
WEBUI_NAME,
39+
pinnedNotes
3940
} from '$lib/stores';
4041
4142
import { downloadPdf } from './utils';
@@ -64,7 +65,9 @@
6465
deleteNoteById,
6566
getNoteById,
6667
updateNoteById,
67-
updateNoteAccessGrants
68+
updateNoteAccessGrants,
69+
toggleNotePinnedStatusById,
70+
getPinnedNoteList
6871
} from '$lib/apis/notes';
6972
7073
import RichTextInput from '../common/RichTextInput.svelte';
@@ -1088,6 +1091,12 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
10881091
onDelete={() => {
10891092
showDeleteConfirm = true;
10901093
}}
1094+
isPinned={note.is_pinned ?? false}
1095+
onPin={async () => {
1096+
await toggleNotePinnedStatusById(localStorage.token, note.id);
1097+
note = await getNoteById(localStorage.token, note.id);
1098+
pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => []));
1099+
}}
10911100
>
10921101
<div class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg">
10931102
<EllipsisHorizontal className="size-5" />

0 commit comments

Comments
 (0)