mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-25 15:04:49 -06:00
refac
This commit is contained in:
@@ -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')
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
</Folder>
|
||||
{/if}
|
||||
|
||||
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
|
||||
<Folder
|
||||
id="sidebar-pinned-notes"
|
||||
bind:open={showPinnedNotes}
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Notes')}
|
||||
chevron={false}
|
||||
dragAndDrop={false}
|
||||
>
|
||||
<div class="mt-0.5 pb-1.5">
|
||||
{#each $pinnedNotes as note (note.id)}
|
||||
<a
|
||||
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"
|
||||
href={`/notes/${note.id}`}
|
||||
on:click={() => {
|
||||
itemClickHandler();
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class="self-center">
|
||||
<Note className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
<div class="flex-1 text-ellipsis line-clamp-1">
|
||||
{note.title}
|
||||
</div>
|
||||
<button
|
||||
class="invisible group-hover:visible self-center p-0.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg transition"
|
||||
on:click|preventDefault|stopPropagation={async () => {
|
||||
await toggleNotePinnedStatusById(localStorage.token, note.id);
|
||||
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
|
||||
pinnedNotes.set(_pinnedNotes);
|
||||
}}
|
||||
aria-label={$i18n.t('Unpin')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Folder>
|
||||
{/if}
|
||||
|
||||
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
|
||||
<Folder
|
||||
id="sidebar-channels"
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
showSidebar,
|
||||
socket,
|
||||
user,
|
||||
WEBUI_NAME
|
||||
WEBUI_NAME,
|
||||
pinnedNotes
|
||||
} from '$lib/stores';
|
||||
|
||||
import { downloadPdf } from './utils';
|
||||
@@ -64,7 +65,9 @@
|
||||
deleteNoteById,
|
||||
getNoteById,
|
||||
updateNoteById,
|
||||
updateNoteAccessGrants
|
||||
updateNoteAccessGrants,
|
||||
toggleNotePinnedStatusById,
|
||||
getPinnedNoteList
|
||||
} from '$lib/apis/notes';
|
||||
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
@@ -1088,6 +1091,12 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||
onDelete={() => {
|
||||
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(() => []));
|
||||
}}
|
||||
>
|
||||
<div class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg">
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
@@ -602,6 +610,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();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
import Link from '$lib/components/icons/Link.svelte';
|
||||
import Pin from '$lib/components/icons/Pin.svelte';
|
||||
import PinSlash from '$lib/components/icons/PinSlash.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -16,6 +18,8 @@
|
||||
|
||||
export let onDownload = (type) => {};
|
||||
export let onDelete = () => {};
|
||||
export let onPin = null;
|
||||
export let isPinned = false;
|
||||
|
||||
export let onCopyLink = null;
|
||||
export let onCopyToClipboard = null;
|
||||
@@ -110,7 +114,25 @@
|
||||
</DropdownSub>
|
||||
{/if}
|
||||
|
||||
{#if onPin}
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
onPin();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{#if isPinned}
|
||||
<PinSlash />
|
||||
<div class="flex items-center">{$i18n.t('Unpin')}</div>
|
||||
{:else}
|
||||
<Pin />
|
||||
<div class="flex items-center">{$i18n.t('Pin to Sidebar')}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
|
||||
@@ -59,6 +59,7 @@ export const channelId = writable(null);
|
||||
|
||||
export const chats = writable(null);
|
||||
export const pinnedChats = writable([]);
|
||||
export const pinnedNotes = writable([]);
|
||||
export const tags = writable([]);
|
||||
export const folders = writable([]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user