mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-19 01:51:15 -05:00
refac
This commit is contained in:
1 parent
6768f52aca
commit
b4db56ae96
18 files changed
+1301
-1013
No files matched your search
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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))
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user