This commit is contained in:
Timothy Jaeryang Baek committed 2026-10-16 02:46:20 +04:00
1 parent 6768f52aca
commit b4db56ae96
18 files changed
+1301 -1013

No files matched your search

+2
View File
@@ -177,6 +177,7 @@ from open_webui.routers import (
prompts,
retrieval,
scim,
search,
skills,
tasks,
terminals,
@@ -894,6 +895,7 @@ app.include_router(apps.router, prefix='/api/v1/apps', tags=['apps'])
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(search.router, prefix='/api/v1/search', tags=['search'])
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
+20 -7
View File
@@ -408,6 +408,9 @@ class MessageTable:
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
*,
around: str | None = None,
before: str | None = None,
) -> list[MessageReplyToResponse]:
async with get_async_db_context(db) as db:
message = await db.get(Message, parent_id)
@@ -416,13 +419,23 @@ class MessageTable:
if not message or message.channel_id != channel_id:
return []
result = await db.execute(
select(Message)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
)
stmt = select(Message).filter_by(channel_id=channel_id, parent_id=parent_id)
if around or before:
anchor = await db.get(Message, before or around)
if not anchor or anchor.channel_id != channel_id or (anchor.id != parent_id and anchor.parent_id != parent_id):
return []
if before:
stmt = stmt.where(or_(Message.created_at < anchor.created_at,
and_(Message.created_at == anchor.created_at, Message.id < anchor.id)))
skip = 0
else:
# A bounded window starts just above the match, even in very old threads.
newer = select(func.count()).select_from(Message).where(
Message.channel_id == channel_id, Message.parent_id == parent_id,
or_(Message.created_at > anchor.created_at,
and_(Message.created_at == anchor.created_at, Message.id > anchor.id)))
skip = max(0, (await db.scalar(newer)) - 10)
result = await db.execute(stmt.order_by(Message.created_at.desc(), Message.id.desc()).offset(skip).limit(limit))
all_messages = list(result.scalars().all())
# If length of all_messages is less than limit, then add the parent message
+3 -1
View File
@@ -1685,6 +1685,8 @@ async def get_channel_thread_messages(
message_id: str,
skip: int = 0,
limit: int = 50,
around: str | None = None,
before: str | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -1700,7 +1702,7 @@ async def get_channel_thread_messages(
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
message_list = await Messages.get_messages_by_parent_id(id, message_id, skip, limit, db=db)
message_list = await Messages.get_messages_by_parent_id(id, message_id, skip, min(max(limit, 1), 100), db=db, around=around, before=before)
if not message_list:
return []
+426
View File
@@ -0,0 +1,426 @@
"""Permission-scoped workspace search. Shared snapshots never read the live chat."""
from typing import Literal
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrant, AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.chats import Chat
from open_webui.models.config import Config
from open_webui.models.folders import Folder
from open_webui.models.groups import Groups
from open_webui.models.messages import Message
from open_webui.models.notes import Note, PinnedNote
from open_webui.models.shared_chats import SharedChat
from open_webui.models.users import User
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from pydantic import BaseModel, Field
from sqlalchemy import BigInteger, and_, case, cast, func, literal, or_, select, text, union_all
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
PAGE_SIZE = 24
class SearchResult(BaseModel):
id: str
kind: Literal['chat', 'message', 'note']
title: str
context: str
snippet: str
preview: str
url: str
updated_at: int
shared: bool = False
pinned: bool = False
rank: int = Field(exclude=True, default=3)
class SearchScope(BaseModel):
id: str
name: str
kind: Literal['folder', 'channel']
class SearchResponse(BaseModel):
items: list[SearchResult]
has_more: bool
scopes: list[SearchScope]
kinds: list[str]
def terms_for(query: str) -> list[str]:
return list(dict.fromkeys(query.lower().split()))[:12]
def pattern(value: str) -> str:
return '%' + value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%'
def excerpt(content: str, query: str, length: int = 200) -> str:
content = content or ''
positions = [content.lower().find(term) for term in terms_for(query)]
position = min((p for p in positions if p >= 0), default=0)
start = max(0, position - length // 4)
return (
('…' if start else '')
+ content[start : start + length].strip()
+ ('…' if start + length < len(content) else '')
)
def chat_passage(data: dict, query: str) -> tuple[str, str | None]:
history = (data.get('history') or {}).get('messages') or {}
messages = list(history.values()) if history else data.get('messages') or []
messages = [m for m in messages if isinstance(m, dict) and isinstance(m.get('content'), str)]
terms = terms_for(query)
match = next((m for m in messages if terms and all(t in m['content'].lower() for t in terms)), None)
if match is None:
match = next((m for m in messages if any(t in m['content'].lower() for t in terms)), None)
fallback = messages[-1] if messages else {}
return (match or fallback).get('content', ''), match.get('id') if match else None
def title_rank(title, query: str):
if not query:
return literal(3)
return case((func.lower(title) == query.lower(), 0), (title.ilike(pattern(query), escape='\\'), 1), else_=3)
def message_match_sql(table: str, dialect: str, key: str):
# Identifiers are internal constants; all user input remains bound parameters.
if dialect == 'sqlite':
sources = [f"json_each({table}.chat, '$.history.messages')", f"json_each({table}.chat, '$.messages')"]
return text(
'('
+ ' OR '.join(
f"EXISTS (SELECT 1 FROM {source} AS m WHERE json_type(m.value, '$.content') = 'text' "
f"AND lower(m.value ->> 'content') LIKE :{key} ESCAPE '\\')"
for source in sources
)
+ ')'
)
sources = [f"json_each({table}.chat->'history'->'messages')", f"json_array_elements({table}.chat->'messages')"]
return text(
'('
+ ' OR '.join(
f"EXISTS (SELECT 1 FROM {source} AS m WHERE json_typeof(m.value->'content') = 'string' "
f"AND lower(m.value->>'content') LIKE :{key} ESCAPE '\\')"
for source in sources
)
+ ')'
)
async def search_workspace( # noqa: C901 — keep permission scoping and source dispatch together
db: AsyncSession,
user,
query: str = '',
kind: str = 'all',
sharing: str = 'all',
scope: str = '',
page: int = 1,
enabled: tuple[str, ...] = ('chat', 'message', 'note'),
) -> SearchResponse:
groups = [g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)]
principal = or_(
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user.id),
and_(AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(groups)),
)
def granted(resource, resource_id, public=False):
principals = (
or_(principal, and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'))
if public
else principal
)
return (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == 'read',
principals,
)
.exists()
)
# UNION (not UNION ALL) terminates even if malformed folder data contains a cycle.
folders = (
select(Folder.id)
.where(or_(Folder.user_id == user.id, granted('folder', Folder.id, True)))
.cte('search_folders', recursive=True)
)
folders = folders.union(select(Folder.id).join(folders, Folder.parent_id == folders.c.id))
folder_rows = (
await db.execute(
select(Folder.id, Folder.name, Folder.parent_id, Folder.user_id).where(Folder.id.in_(select(folders.c.id)))
)
).all()
folder_map = {f.id: f for f in folder_rows}
channels = await Channels.get_channels_by_user_id(user.id, db=db) if 'message' in enabled else []
channel_map = {c.id: c for c in channels}
scopes = [SearchScope(id='folder:' + f.id, name=f.name, kind='folder') for f in folder_rows]
scopes += [SearchScope(id='channel:' + c.id, name=c.name, kind='channel') for c in channels]
scopes.sort(key=lambda s: (s.kind, s.name.lower(), s.id))
shared_folder_ids = set(
(
await db.execute(
select(Folder.id).where(
Folder.id.in_(folder_map),
select(AccessGrant.id)
.where(
AccessGrant.resource_type == 'folder',
AccessGrant.resource_id == Folder.id,
AccessGrant.permission == 'read',
)
.exists(),
)
)
).scalars()
)
while True:
children = {f.id for f in folder_rows if f.parent_id in shared_folder_ids}
if children.issubset(shared_folder_ids):
break
shared_folder_ids |= children
scoped_folders = set(folder_map)
if scope.startswith('folder:'):
root = scope.removeprefix('folder:')
scoped_folders = {root} if root in folder_map else set()
while True:
children = {f.id for f in folder_rows if f.parent_id in scoped_folders}
if children.issubset(scoped_folders):
break
scoped_folders |= children
if scope and scope not in {s.id for s in scopes}:
return SearchResponse(items=[], has_more=False, scopes=scopes, kinds=list(enabled))
query = query.strip()
terms = terms_for(query)
results = []
dialect = db.bind.dialect.name
def sharing_filter(owner, shared):
if sharing == 'mine':
return owner == user.id
if sharing == 'shared':
return shared
return True
def apply_match(stmt, model, body=None):
params = {}
for index, term in enumerate(terms):
key = f'search_{index}'
params[key] = pattern(term)
content_match = (
body.ilike(pattern(term), escape='\\')
if body is not None
else message_match_sql(model.__tablename__, dialect, key)
)
stmt = stmt.where(or_(model.title.ilike(pattern(term), escape='\\'), content_match))
return stmt.params(**params)
def append_result(id, result_kind, title, context, content, url, updated, shared, pinned=False, rank=3):
results.append(
SearchResult(
id=id,
kind=result_kind,
title=title or 'Untitled',
context=context,
snippet=excerpt(content, query),
preview=excerpt(content, query, 2400),
url=url,
updated_at=updated,
shared=shared,
pinned=bool(pinned),
rank=rank,
)
)
sources = {}
if 'chat' in enabled and kind in ('all', 'chat') and not scope.startswith('channel:'):
continue_shared = select(SharedChat.chat_id).where(
SharedChat.chat['share_mode'].as_string() == 'continue', granted('shared_chat', SharedChat.chat_id)
)
live_access = or_(
Chat.user_id == user.id, Chat.folder_id.in_(select(folders.c.id)), Chat.id.in_(continue_shared)
)
live_ids = select(Chat.id).where(live_access)
shared = or_(Chat.user_id != user.id, Chat.share_id.is_not(None), Chat.folder_id.in_(shared_folder_ids))
rank = (
title_rank(Chat.title, query)
if query
else case((and_(Chat.pinned.is_(True), Chat.user_id == user.id), 0), else_=3)
)
stmt = select(Chat).where(
live_access,
Chat.archived.is_(False),
func.coalesce(Chat.meta['internal'].as_boolean(), False).is_(False),
sharing_filter(Chat.user_id, shared),
)
if scope:
stmt = stmt.where(Chat.folder_id.in_(scoped_folders))
sources['chat'] = (Chat, apply_match(stmt, Chat), rank, Chat.updated_at)
if not scope and sharing != 'mine':
stmt = select(SharedChat).where(
granted('shared_chat', SharedChat.chat_id), ~SharedChat.chat_id.in_(live_ids)
)
sources['shared'] = (
SharedChat,
apply_match(stmt, SharedChat),
title_rank(SharedChat.title, query),
SharedChat.updated_at,
)
if 'note' in enabled and kind in ('all', 'note') and not scope:
pinned = select(PinnedNote.id).where(PinnedNote.note_id == Note.id, PinnedNote.user_id == user.id).exists()
note_shared = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == 'note',
AccessGrant.resource_id == Note.id,
AccessGrant.permission == 'read',
)
.exists()
)
stmt = AccessGrants.has_permission_filter(
db, select(Note, pinned, note_shared), Note, {'user_id': user.id, 'group_ids': groups}, 'note', 'read'
)
stmt = stmt.where(sharing_filter(Note.user_id, or_(Note.user_id != user.id, note_shared)))
stmt = apply_match(stmt, Note, Note.data['content']['md'].as_string())
rank = title_rank(Note.title, query) if query else case((pinned, 0), else_=3)
sources['note'] = (Note, stmt, rank, cast(Note.updated_at.op('/')(literal(1_000_000_000)), BigInteger))
if 'message' in enabled and kind in ('all', 'message') and not scope.startswith('folder:'):
ids = [scope.removeprefix('channel:')] if scope else list(channel_map)
stmt = select(Message, User.name).outerjoin(User, User.id == Message.user_id).where(Message.channel_id.in_(ids))
if sharing == 'mine':
stmt = stmt.where(Message.user_id == user.id)
for term in terms:
stmt = stmt.where(Message.content.ilike(pattern(term), escape='\\'))
sources['message'] = (
Message,
stmt,
literal(3),
cast(Message.updated_at.op('/')(literal(1_000_000_000)), BigInteger),
)
if not sources:
return SearchResponse(items=[], has_more=False, scopes=scopes, kinds=list(enabled))
# Page one combined, permission-filtered ID query. Only hydrate the 24 visible
# results, rather than repeatedly loading full conversation JSON while scrolling.
candidates = union_all(
*[
stmt.with_only_columns(
literal(source).label('source'),
model.id.label('id'),
rank.label('rank'),
updated.label('updated_at'),
maintain_column_froms=True,
)
for source, (model, stmt, rank, updated) in sources.items()
]
).subquery()
matches = (
await db.execute(
select(candidates)
.order_by(candidates.c.rank, candidates.c.updated_at.desc(), candidates.c.source, candidates.c.id)
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE + 1)
)
).all()
has_more = len(matches) > PAGE_SIZE
matches = matches[:PAGE_SIZE]
hydrated = {}
for source, (model, stmt, _, _) in sources.items():
ids = [match.id for match in matches if match.source == source]
if ids:
hydrated[source] = {row[0].id: row for row in (await db.execute(stmt.where(model.id.in_(ids)))).all()}
for match in matches:
row = hydrated.get(match.source, {}).get(match.id)
if row is None:
continue # Access may have been revoked between selecting IDs and hydrating.
item = row[0]
if match.source in ('chat', 'shared'):
content, message_id = chat_passage(item.chat or {}, query)
snapshot = match.source == 'shared'
folder = None if snapshot else folder_map.get(item.folder_id)
context = (
'Shared snapshot'
if snapshot
else (folder.name if folder else ('Shared chat' if item.user_id != user.id else 'Personal chat'))
)
url = ('/s/' if snapshot else '/c/') + item.id
if message_id:
url += '?' + urlencode({'message': message_id})
is_shared = (
snapshot or item.user_id != user.id or bool(item.share_id) or item.folder_id in shared_folder_ids
)
append_result(
match.source + ':' + item.id,
'chat',
item.title,
context,
content,
url,
match.updated_at,
is_shared,
not snapshot and item.pinned and item.user_id == user.id,
match.rank,
)
elif match.source == 'note':
content = ((item.data or {}).get('content') or {}).get('md') or ''
append_result(
'note:' + item.id,
'note',
item.title,
'Shared note' if item.user_id != user.id or row[2] else 'Personal note',
content if isinstance(content, str) else '',
'/notes/' + item.id + ('?' + urlencode({'search': query}) if query else ''),
match.updated_at,
item.user_id != user.id or row[2],
row[1],
match.rank,
)
else:
channel = channel_map[item.channel_id]
url = '/channels/' + channel.id + '?' + urlencode({'thread': item.parent_id or item.id, 'message': item.id})
append_result(
'message:' + item.id,
'message',
row[1] or 'Message',
'#' + channel.name,
item.content,
url,
match.updated_at,
True,
rank=match.rank,
)
return SearchResponse(items=results, has_more=has_more, scopes=scopes, kinds=list(enabled))
@router.get('', response_model=SearchResponse)
async def search(
q: str = Query('', max_length=500),
kind: Literal['all', 'chat', 'message', 'note'] = 'all',
sharing: Literal['all', 'mine', 'shared'] = 'all',
scope: str = Query('', max_length=200),
page: int = Query(1, ge=1, le=100_000),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
enabled = ['chat']
permissions = await Config.get('user.permissions')
for feature, result_kind in [('channels', 'message'), ('notes', 'note')]:
if await Config.get(feature + '.enable') and (
user.role == 'admin' or await has_permission(user.id, 'features.' + feature, permissions, db=db)
):
enabled.append(result_kind)
return await search_workspace(db, user, q, kind, sharing, scope, page, tuple(enabled))
+3 -2
View File
@@ -479,12 +479,13 @@ export const getChannelThreadMessages = async (
channel_id: string,
message_id: string,
skip: number = 0,
limit: number = 50
limit: number = 50,
anchor: { around?: string; before?: string } = {}
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}&${new URLSearchParams(anchor)}`,
{
method: 'GET',
headers: {
+36
View File
@@ -0,0 +1,36 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type SearchKind = 'chat' | 'message' | 'note';
export type SearchResult = {
id: string;
kind: SearchKind;
title: string;
context: string;
snippet: string;
preview: string;
url: string;
updated_at: number;
shared: boolean;
pinned: boolean;
};
export type SearchScope = { id: string; name: string; kind: 'folder' | 'channel' };
export type SearchResponse = {
items: SearchResult[];
has_more: boolean;
scopes: SearchScope[];
kinds: SearchKind[];
};
export const searchWorkspace = async (
token: string,
filters: { q: string; kind: string; sharing: string; scope: string; page: number },
signal: AbortSignal
): Promise<SearchResponse> => {
const params = new URLSearchParams({ ...filters, page: String(filters.page) });
const response = await fetch(`${WEBUI_API_BASE_URL}/search?${params}`, {
headers: { Authorization: `Bearer ${token}` },
signal
});
if (!response.ok) throw new Error('Search is unavailable. Please try again.');
return response.json();
};
+3 -1
View File
@@ -106,7 +106,9 @@
$: if (channel && $page.url.searchParams.get('thread')) {
threadId = $page.url.searchParams.get('thread');
window.history.replaceState(history.state, '', `/channels/${id}`);
if (!$page.url.searchParams.has('message')) {
window.history.replaceState(history.state, '', `/channels/${id}`);
}
}
const scrollToBottom = () => {
+42 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { socket, user } from '$lib/stores';
@@ -21,6 +22,8 @@
export let onPin = () => {};
let messages = null;
let searchAnchor = '';
let loadVersion = 0;
let top = false;
let messagesContainerElement = null;
@@ -32,7 +35,7 @@
let typingUsersTimeout = {};
$: if (threadId) {
initHandler();
initHandler($page.url.searchParams.get('message') ?? '');
}
const scrollToBottom = () => {
@@ -41,7 +44,9 @@
}
};
const initHandler = async () => {
const initHandler = async (anchor = '') => {
const version = ++loadVersion;
searchAnchor = anchor;
messages = null;
top = false;
@@ -49,14 +54,32 @@
typingUsersTimeout = {};
if (channel) {
messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
const loaded = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
0,
50,
{ around: anchor }
).catch((error) => {
toast.error(String(error));
return [];
});
if (version !== loadVersion) return;
messages = loaded;
if (messages.length < 50) {
top = true;
}
await tick();
scrollToBottom();
if (anchor) {
const element = document.getElementById(`message-${threadId}-${anchor}`);
element?.scrollIntoView({ block: 'center' });
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
duration: 2200
});
} else scrollToBottom();
} else {
goto('/');
}
@@ -217,6 +240,12 @@
bind:this={messagesContainerElement}
>
<div class="pt-3">
{#if searchAnchor}<div class="px-3 py-2 text-xs text-gray-500 flex justify-between">
<span>{$i18n.t('Showing search match')}</span><button
class="underline"
on:click={() => initHandler()}>{$i18n.t('Latest replies')}</button
>
</div>{/if}
{#if messages !== null}
<Messages
id={threadId}
@@ -237,10 +266,17 @@
localStorage.token,
channel.id,
threadId,
messages.length
searchAnchor ? 0 : messages.length,
50,
searchAnchor ? { before: messages[messages.length - 1]?.id } : {}
);
messages = [...messages, ...newMessages];
messages = [
...messages,
...newMessages.filter(
(item) => !messages.some((existing) => existing.id === item.id)
)
];
if (newMessages.length < 50) {
top = true;
+13 -1
View File
@@ -907,7 +907,7 @@
await tick();
loading = false;
noteChatDebug('embedded chat loading false');
window.setTimeout(() => scrollToBottom(), 0);
if (!$page.url.searchParams.has('message')) window.setTimeout(() => scrollToBottom(), 0);
await tick();
@@ -1221,6 +1221,18 @@
}
};
let revealedSearchMessage = '';
$: if (!embedded && !loading && messagesRef && chat?.id === $page.params.id) {
const target = $page.url.searchParams.get('message');
const key = `${chat.id}:${target}`;
if (target && history.messages[target] && key !== revealedSearchMessage) {
revealedSearchMessage = key;
autoScroll = false;
history.currentId = getDeepestChildId(history, target);
tick().then(() => messagesRef?.scrollToMessage(target));
} else if (!target) revealedSearchMessage = '';
}
const showMessage = async (message, scroll = true, save = true) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
+13
View File
@@ -161,6 +161,19 @@
}
};
export const scrollToMessage = async (id: string) => {
messagesCount = null;
buildMessages();
await tick();
const element = getMessagesContainer()?.querySelector<HTMLElement>(
`#${CSS.escape(`message-${id}`)}`
);
element?.scrollIntoView({ block: 'center' });
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
duration: 2200
});
};
export const scrollToTop = async () => {
messagesCount = null;
buildMessages();
@@ -0,0 +1,16 @@
<script lang="ts">
export let text = '';
export let query = '';
// Render text nodes, never HTML from a search result.
$: terms = [...new Set(query.trim().split(/\s+/).filter(Boolean))].slice(0, 12);
$: expression = terms.length
? new RegExp(
`(${terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`,
'gi'
)
: null;
$: parts = expression ? text.split(expression) : [text];
</script>
{#each parts as part, index}{#if expression && index % 2 === 1}<mark>{part}</mark
>{:else}{part}{/if}{/each}
@@ -0,0 +1,53 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import { getNoteById } from '$lib/apis/notes';
import PreviewMarkdown from './PreviewMarkdown.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n: any = getContext('i18n');
export let id: string;
export let query = '';
let content = '';
let loading = true;
let error = false;
let generation = 0;
let body: HTMLDivElement;
$: load(id, query);
async function load(noteId: string, search: string) {
const version = ++generation;
loading = true;
error = false;
content = '';
const note = await getNoteById(localStorage.token, noteId).catch(() => null);
if (version !== generation) return;
if (note) content = note.data?.content?.md ?? '';
else error = true;
loading = false;
await tick();
if (version !== generation || !body || !search.trim()) return;
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (terms.some((term) => node!.textContent?.toLowerCase().includes(term))) {
node.parentElement?.scrollIntoView({ block: 'center' });
break;
}
}
}
onDestroy(() => (generation += 1));
</script>
{#if loading}<div class="flex justify-center py-6 text-gray-400">
<Spinner className="size-4" />
</div>
{:else if error}<div class="py-4 text-xs text-gray-500 dark:text-gray-400">
<p>{$i18n.t('Failed to load note preview')}</p>
<button class="mt-2 underline underline-offset-2" on:click={() => load(id, query)}
>{$i18n.t('Try again')}</button
>
</div>
{:else}<div bind:this={body}>
<PreviewMarkdown id={`note-search-preview-${id}`} {content} />
</div>{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
export let id: string;
export let content: string;
</script>
<div
class="markdown-prose !text-xs !leading-snug font-normal [&_p]:!text-xs [&_p]:!leading-snug [&_h1]:!text-xs [&_h2]:!text-xs [&_h3]:!text-xs [&_h4]:!text-xs [&_h5]:!text-xs [&_h6]:!text-xs [&_h1]:!font-normal [&_h2]:!font-normal [&_h3]:!font-normal [&_h4]:!font-normal [&_h5]:!font-normal [&_h6]:!font-normal [&_strong]:!font-normal [&_a]:!font-normal [&_th]:!font-normal [&_pre]:!text-[0.6875rem] [&_ul]:!my-1 [&_ol]:!my-1 [&_li]:!leading-snug"
>
<Markdown
{id}
{content}
compactPreview={true}
preview={true}
editCodeBlock={false}
allowHtml={false}
/>
</div>
File diff suppressed because it is too large. Load diff
@@ -1,94 +1,15 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import { getContext } from 'svelte';
import { LinkPreview } from 'bits-ui';
import { getChatById } from '$lib/apis/chats';
import Messages from '$lib/components/chat/Messages.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { user } from '$lib/stores';
import ChatPreview from './ChatPreview.svelte';
const i18n: any = getContext('i18n');
export let chatId = '';
export let title = '';
export let openPreview = false;
export let side: 'left' | 'right' | 'bottom' | 'top' = 'right';
export let align: 'start' | 'end' | 'center' = 'start';
export let sideOffset = 12;
let messagesContainerElement: HTMLElement | null = null;
let selectedModels = [''];
let history: any = null;
let previewReady = false;
let loading = false;
let error = '';
let requestedChatId = '';
let loadToken = 0;
$: messagesContainerId = `chat-hover-preview-messages-${chatId}`;
const scrollPreviewToBottom = async () => {
await tick();
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
});
}
});
setTimeout(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}, 80);
};
const loadChatPreview = async (id: string) => {
if (!id || loading || requestedChatId === id) return;
const token = ++loadToken;
requestedChatId = id;
loading = true;
error = '';
previewReady = false;
history = null;
selectedModels = [''];
const chat = await getChatById(localStorage.token, id).catch(() => null);
if (token !== loadToken) return;
if (chat?.chat?.history) {
selectedModels = Array.isArray(chat.chat.models)
? chat.chat.models
: [chat.chat.models ?? ''];
history = chat.chat.history;
previewReady = true;
scrollPreviewToBottom();
} else if (chat) {
previewReady = true;
} else {
error = $i18n.t('Failed to load chat preview');
}
loading = false;
};
$: if (openPreview && chatId && requestedChatId !== chatId) {
loadChatPreview(chatId);
}
$: if (openPreview && previewReady) {
scrollPreviewToBottom();
}
onDestroy(() => {
loadToken += 1;
});
</script>
{#if openPreview}
@@ -101,50 +22,15 @@
>
<div class="border-b border-gray-50/60 px-3 py-2 dark:border-gray-800/25">
<div
class="truncate text-[0.8125rem] font-medium leading-5 text-gray-900 dark:text-gray-100"
class="truncate text-[0.8125rem] font-normal leading-5 text-gray-900 dark:text-gray-100"
>
{title || $i18n.t('Chat')}
</div>
</div>
<div
id={messagesContainerId}
bind:this={messagesContainerElement}
class="max-h-[min(15rem,calc(100vh-4rem))] overflow-y-auto bg-white scrollbar-hover @container dark:bg-gray-850"
>
{#if loading}
<div
class="flex w-full items-center justify-center py-8 text-gray-500 dark:text-gray-400"
>
<Spinner className="size-5" />
</div>
{:else if error}
<div
class="flex w-full items-center justify-center px-6 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{error}
</div>
{:else if previewReady}
<Messages
className="flex w-full pt-2 pb-0 [&_.message-listitem]:!mb-1 [&_.message-listitem]:!max-w-none [&_.message-listitem]:!px-3 [&_.pb-18]:!pb-1.5 [&_.markdown-prose]:!text-xs [&_.markdown-prose]:!leading-snug [&_.whitespace-pre-wrap]:!text-xs [&_.whitespace-pre-wrap]:!leading-snug [&_.text-\[0\.9375rem\]]:!text-xs [&_.text-sm]:!text-xs [&_.tool-call-body_pre]:!text-[0.6875rem] [&_.rounded-3xl]:!rounded-2xl [&_.chat-user_.rounded-3xl]:!bg-gray-50 dark:[&_.chat-user_.rounded-3xl]:!bg-gray-800 [&_.px-4]:!px-3 [&_.py-3]:!py-2 [&_.py-1\.5]:!py-1 [&_button]:!pointer-events-none"
chatId={`chat-hover-preview-${chatId}`}
user={$user}
prompt=""
readOnly={true}
compactPreview={true}
{selectedModels}
atSelectedModel={null}
{history}
autoScroll={true}
{messagesContainerId}
messagesCount={8}
sendMessage={() => {}}
continueResponse={() => {}}
regenerateResponse={() => {}}
mergeResponses={() => {}}
chatActionHandler={() => {}}
/>
{/if}
</div>
<ChatPreview
{chatId}
className="max-h-[min(15rem,calc(100vh-4rem))] bg-white dark:bg-gray-850"
/>
</LinkPreview.Content>
</LinkPreview.Portal>
{/if}
@@ -0,0 +1,89 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import { getChatById, getChatByShareId } from '$lib/apis/chats';
import { convertMessagesToHistory, getDeepestChildId } from '$lib/utils';
import Messages from '$lib/components/chat/Messages.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { user } from '$lib/stores';
const i18n: any = getContext('i18n');
export let chatId = '';
export let shareId = '';
export let messageId = '';
export let className = 'h-full';
let container: HTMLDivElement;
let messagesComponent: Messages;
let history: any = null;
let selectedModels: string[] = [];
let loading = true;
let error = false;
let generation = 0;
$: containerId = `chat-preview-${shareId || chatId}`;
$: load(chatId, shareId, messageId);
async function load(id: string, shared: string, target: string) {
const version = ++generation;
loading = true;
error = false;
history = null;
if (!id && !shared) {
loading = false;
return;
}
const chat = await (
shared ? getChatByShareId(localStorage.token, shared) : getChatById(localStorage.token, id)
).catch(() => null);
if (version !== generation) return;
if (chat) {
history = chat.chat?.history ?? convertMessagesToHistory(chat.chat?.messages ?? []);
selectedModels = Array.isArray(chat.chat?.models) ? chat.chat.models : [];
if (target && history.messages?.[target])
history.currentId = getDeepestChildId(history, target);
} else error = true;
loading = false;
await tick();
if (version !== generation) return;
if (target && history?.messages?.[target]) await messagesComponent?.scrollToMessage(target);
else if (container) container.scrollTop = container.scrollHeight;
}
onDestroy(() => (generation += 1));
</script>
<div
id={containerId}
bind:this={container}
class="min-h-0 overflow-y-auto scrollbar-hover @container {className}"
>
{#if loading}<div
class="flex h-full min-h-20 items-center justify-center text-gray-500 dark:text-gray-400"
>
<Spinner className="size-4" />
</div>
{:else if error}<div class="px-3 py-6 text-center text-xs text-gray-500 dark:text-gray-400">
<p>{$i18n.t('Failed to load chat preview')}</p>
<button
class="mt-2 underline underline-offset-2"
on:click={() => load(chatId, shareId, messageId)}>{$i18n.t('Try again')}</button
>
</div>
{:else if history}<Messages
bind:this={messagesComponent}
className="flex w-full pt-2 pb-0 [&_.message-listitem]:!mb-1 [&_.message-listitem]:!max-w-none [&_.message-listitem]:!px-3 [&_.pb-18]:!pb-1.5 [&_.markdown-prose]:!text-xs [&_.markdown-prose]:!leading-snug [&_.whitespace-pre-wrap]:!text-xs [&_.whitespace-pre-wrap]:!leading-snug [&_.text-\[0\.9375rem\]]:!text-xs [&_.text-sm]:!text-xs [&_.tool-call-body_pre]:!text-[0.6875rem] [&_.rounded-3xl]:!rounded-2xl [&_.chat-user_.rounded-3xl]:!bg-gray-50 dark:[&_.chat-user_.rounded-3xl]:!bg-gray-800 [&_.px-4]:!px-3 [&_.py-3]:!py-2 [&_.py-1\.5]:!py-1 [&_button]:!pointer-events-none [&_.font-medium]:!font-normal [&_.font-semibold]:!font-normal"
chatId={containerId}
user={$user}
prompt=""
readOnly={true}
compactPreview={true}
{selectedModels}
atSelectedModel={null}
{history}
autoScroll={!messageId}
messagesContainerId={containerId}
messagesCount={8}
sendMessage={() => {}}
continueResponse={() => {}}
regenerateResponse={() => {}}
mergeResponses={() => {}}
chatActionHandler={() => {}}
/>{/if}
</div>
@@ -11,6 +11,7 @@
import { equalEditorJSON } from '$lib/utils/editorJson';
import { goto, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import dayjs from '$lib/dayjs';
import calendar from 'dayjs/plugin/calendar';
@@ -87,6 +88,29 @@
export let id: null | string = null;
let editor = null;
let revealedSearch = '';
$: if (editor && note?.id === id && $page.url.searchParams.get('search')) {
const query = $page.url.searchParams.get('search')!.trim().toLowerCase();
const key = `${id}:${query}`;
if (query && revealedSearch !== key) {
revealedSearch = key;
tick().then(() => {
if (!editor || note?.id !== id) return;
let found = false;
editor.state.doc.descendants((node, position) => {
if (found || !node.isText) return;
const text = (node.text ?? '').toLowerCase();
const term = [query, ...query.split(/\s+/)].find((term) => text.includes(term));
if (term) {
const from = position + text.indexOf(term);
editor.commands.setTextSelection({ from, to: from + term.length });
editor.commands.scrollIntoView();
found = true;
}
});
});
}
}
let autoFormat = true;
let note = null;
+9 -3
View File
@@ -7,7 +7,7 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { settings, chatId, WEBUI_NAME, models, config, user as sessionUser } from '$lib/stores';
import { convertMessagesToHistory, createMessagesList } from '$lib/utils';
import { convertMessagesToHistory, createMessagesList, getDeepestChildId } from '$lib/utils';
import { getChatByShareId, cloneSharedChatById, forkChatById } from '$lib/apis/chats';
@@ -53,7 +53,13 @@
if (await loadSharedChat()) {
loaded = true;
await tick();
messagesComponent?.scrollToBottom();
const target = $page.url.searchParams.get('message');
if (target && history.messages[target]) {
autoScroll = false;
history.currentId = getDeepestChildId(history, target);
await tick();
await messagesComponent?.scrollToMessage(target);
} else messagesComponent?.scrollToBottom();
} else if (localStorage.token) {
await goto('/');
} else {
@@ -109,7 +115,7 @@
chat = await getChatByShareId(token, shareId).catch(() => null);
if (chat?.chat?.share_mode === 'continue' && chat.id !== shareId) {
await goto(`/c/${chat.id}`, { replaceState: true });
await goto(`/c/${chat.id}${$page.url.search}`, { replaceState: true });
return true;
}