Merge remote-tracking branch 'upstream/dev' into HEAD

# Conflicts:
#	backend/open_webui/utils/anthropic.py
This commit is contained in:
Classic298 2026-07-23 18:42:03 +02:00
commit 3f567a21f2
56 changed files with 2876 additions and 1423 deletions

View file

@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.

View file

@ -1193,6 +1193,20 @@ async def publish_model_provider_request_failed(
else 'upstream_error'
)
# Server-log only; the upstream error body is otherwise invisible to admins
# (event sinks require an event function or webhook to be configured).
log.log(
logging.ERROR if status >= 500 else logging.WARNING,
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
provider,
status,
error_type,
base_url,
requested_model or '-',
error_code or '-',
error_text[:MAX_STRING_LENGTH] or '-',
)
data = {
'error_type': error_type,
'status': status,

View file

@ -147,7 +147,10 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
if models is None:
models = {}
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)

View file

@ -1795,8 +1795,23 @@ async def generate_messages(
# Counting must not turn a compatible generation request into an outage.
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
passthrough_params = []
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await openai.get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
passthrough_params = api_config.get('passthrough_params') or []
# Convert Anthropic payload to OpenAI format
openai_payload = convert_anthropic_to_openai_payload(form_data)
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)

View file

@ -0,0 +1,223 @@
"""add current_message_id to chat
Revision ID: 9a1b2c3d4e5f
Revises: 856c5b02fb54
Create Date: 2026-07-23 00:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '9a1b2c3d4e5f'
down_revision: Union[str, None] = '856c5b02fb54'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
BATCH_SIZE = 150
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'current_message_id' not in columns:
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
chat = sa.table(
'chat',
sa.column('id', sa.String()),
sa.column('chat', sa.Text()),
sa.column('current_message_id', sa.Text()),
)
chat_message = sa.table(
'chat_message',
sa.column('id', sa.Text()),
sa.column('chat_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
has_chat_message = 'chat_message' in inspector.get_table_names()
result = conn.execute(
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
yield_per=BATCH_SIZE,
stream_results=True,
)
)
while True:
rows = result.fetchmany(BATCH_SIZE)
if not rows:
break
batch_chat_ids: list[str] = []
candidates_by_chat: dict[str, list[str]] = {}
current_by_chat: dict[str, str | None] = {}
json_messages_by_chat: dict[str, dict[str, dict]] = {}
for row in rows:
values = row._mapping
chat_id = values['id']
prefix = f'{chat_id}-'
batch_chat_ids.append(chat_id)
current_by_chat[chat_id] = values['current_message_id']
chat_data = {}
if isinstance(values['chat'], dict):
chat_data = values['chat']
elif isinstance(values['chat'], str):
try:
parsed = json.loads(values['chat'])
chat_data = parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError, json.JSONDecodeError):
pass
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
candidates_by_chat[chat_id] = []
for candidate in (
values['current_message_id'],
history.get('currentId'),
chat_data.get('currentId'),
chat_data.get('branchPointMessageId'),
):
if not isinstance(candidate, str) or not candidate:
continue
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
if candidate not in candidates_by_chat[chat_id]:
candidates_by_chat[chat_id].append(candidate)
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(chat_data.get('messages'), list):
messages = {
message['id']: message
for message in chat_data['messages']
if isinstance(message, dict) and message.get('id')
}
if messages:
json_messages_by_chat[chat_id] = {
message_id: {
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
}
for message_id, message in messages.items()
}
resolved: dict[str, str] = {}
if has_chat_message:
candidate_ids = {
f'{chat_id}-{candidate}'
for chat_id, candidates in candidates_by_chat.items()
for candidate in candidates
}
if candidate_ids:
valid_by_chat: dict[str, set[str]] = {}
for row in conn.execute(
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
chat_message.c.chat_id.in_(batch_chat_ids),
chat_message.c.id.in_(candidate_ids),
)
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if message_id:
valid_by_chat.setdefault(chat_id, set()).add(message_id)
for chat_id, candidates in candidates_by_chat.items():
valid_ids = valid_by_chat.get(chat_id, set())
for candidate in candidates:
if candidate in valid_ids:
resolved[chat_id] = candidate
break
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
messages_by_chat: dict[str, dict[str, dict]] = {}
if unresolved_chat_ids:
for row in conn.execute(
sa.select(
chat_message.c.chat_id,
chat_message.c.id,
chat_message.c.parent_id,
chat_message.c.created_at,
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if not message_id:
continue
parent_id = values['parent_id']
if parent_id and parent_id.startswith(prefix):
parent_id = parent_id[len(prefix) :]
messages_by_chat.setdefault(chat_id, {})[message_id] = {
'parent_id': parent_id,
'created_at': values['created_at'] or 0,
}
for chat_id, messages in messages_by_chat.items():
parent_ids = {
message['parent_id']
for message in messages.values()
if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
for chat_id in batch_chat_ids:
if chat_id in resolved:
continue
messages = json_messages_by_chat.get(chat_id, {})
valid_candidate = next(
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
None,
)
if valid_candidate:
resolved[chat_id] = valid_candidate
elif messages:
parent_ids = {
message['parent_id']
for message in messages.values()
if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
updates = [
{'chat_id': chat_id, 'current_message_id': message_id}
for chat_id, message_id in resolved.items()
if message_id and message_id != current_by_chat.get(chat_id)
]
if updates:
conn.execute(
sa.update(chat)
.where(chat.c.id == sa.bindparam('update_chat_id'))
.values(current_message_id=sa.bindparam('update_current_message_id')),
[
{
'update_chat_id': row['chat_id'],
'update_current_message_id': row['current_message_id'],
}
for row in updates
],
)
def downgrade() -> None:
op.drop_column('chat', 'current_message_id')

View file

@ -62,6 +62,7 @@ class Chat(Base): # database table mapping for chat entity
tasks = Column(JSON, nullable=True)
summary = Column(Text, nullable=True)
current_message_id = Column(Text, nullable=True)
last_read_at = Column(BigInteger, nullable=True)
@ -98,6 +99,7 @@ class ChatModel(BaseModel):
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
last_read_at: int | None = None
@ -145,6 +147,7 @@ class ChatForm(BaseModel):
class ChatImportForm(ChatForm):
meta: dict | None = {}
pinned: bool | None = False
current_message_id: str | None = None
created_at: int | None = None
updated_at: int | None = None
@ -177,6 +180,7 @@ class ChatResponse(BaseModel):
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
context_usage: dict | None = None
@ -280,6 +284,21 @@ class ChatTable:
"""Recursively remove null bytes from strings in dict/list structures."""
return sanitize_data_for_db(obj)
def get_current_message_id(self, chat: dict | None) -> str | None:
chat = chat or {}
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
if current_id:
return current_id
messages = chat.get('messages')
if isinstance(messages, list):
for message in reversed(messages):
if isinstance(message, dict) and message.get('id'):
return message['id']
return None
def _sanitize_chat_row(self, chat_item):
"""
Clean a Chat SQLAlchemy model's title + chat JSON,
@ -375,6 +394,7 @@ class ChatTable:
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'current_message_id': self.get_current_message_id(form_data.chat),
'created_at': int(time.time()),
'updated_at': int(time.time()),
'last_read_at': int(time.time()),
@ -388,8 +408,14 @@ class ChatTable:
# Dual-write initial messages to chat_message table
try:
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
await ChatMessages.upsert_message(
@ -458,6 +484,7 @@ class ChatTable:
'meta': form_data.meta,
'pinned': form_data.pinned,
'folder_id': form_data.folder_id,
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
}
@ -497,8 +524,14 @@ class ChatTable:
# Dual-write messages to chat_message table
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
try:
@ -530,6 +563,8 @@ class ChatTable:
chat_item.chat = self._clean_null_bytes(chat)
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
chat_item.current_message_id = self.get_current_message_id(chat)
if touch:
chat_item.updated_at = int(time.time())
@ -1627,9 +1662,23 @@ class ChatTable:
postgres_content_sql = """
EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS message
WHERE json_typeof(message->'content') = 'string'
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
FROM chat_message AS message
WHERE message.chat_id = Chat.id
AND message.user_id = Chat.user_id
AND json_typeof(message.content) = 'string'
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
WHERE json_typeof(history_message.value->'content') = 'string'
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
WHERE json_typeof(legacy_message->'content') = 'string'
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
)
"""
@ -1722,17 +1771,21 @@ class ChatTable:
folder_id: str,
skip: int = 0,
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
.order_by(order_clause, Chat.id)
)
if skip:
@ -1754,6 +1807,22 @@ class ChatTable:
for chat in all_chats
]
async def count_all_chats_by_folder_id(
self,
folder_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
stmt = (
select(func.count(Chat.id))
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:

View file

@ -48,6 +48,7 @@ API_CONFIG_FIELDS = (
'azure',
'api_version',
'extra_params',
'passthrough_params',
)

View file

@ -3,7 +3,8 @@ from __future__ import annotations
import json
import logging
import time
from typing import Optional
from copy import deepcopy
from typing import Any, Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
_warned_profile_urls: set[str] = set()
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
"""Drop duplicated extracted text from ModelMeta.knowledge."""
if not isinstance(knowledge, list):
return knowledge
sanitized = []
for item in knowledge:
if not isinstance(item, dict):
sanitized.append(item)
continue
next_item = item
data = item.get('data')
if isinstance(data, dict) and 'content' in data:
next_item = deepcopy(item)
next_item.get('data', {}).pop('content', None)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
if isinstance(file_data, dict) and 'content' in file_data:
if next_item is item:
next_item = deepcopy(item)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
file_data.pop('content', None)
sanitized.append(next_item)
return sanitized
# --- Models DB Schema ---
@ -37,6 +70,7 @@ class ModelMeta(BaseModel):
profile_image_url: str | None = None
description: str | None = Field(default=None, description='User-facing description of the model.')
capabilities: dict | None = None
knowledge: list[Any] | None = None
model_config = ConfigDict(extra='allow')
@ -56,6 +90,11 @@ class ModelMeta(BaseModel):
)
return None
@field_validator('knowledge', mode='before')
@classmethod
def strip_knowledge_content(cls, v):
return strip_extracted_content_from_model_knowledge(v)
@model_validator(mode='before')
@classmethod
def normalize_tags(cls, data):
@ -152,6 +191,14 @@ class ModelsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ModelModel:
if isinstance(model.meta, dict):
knowledge = model.meta.get('knowledge')
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
if stripped_knowledge != knowledge:
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
if db is not None:
await db.commit()
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
model_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)

View file

@ -38,6 +38,7 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
from open_webui.utils.misc import get_message_list
from open_webui.utils.models import get_all_models
@ -1167,7 +1168,8 @@ async def compact_chat_by_id(
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id)
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
current_message_id = chat.current_message_id or history.get('currentId')
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
model_id = (form_data.model if form_data else None) or next(
(message.get('model') for message in reversed(message_list) if message.get('model')),
None,
@ -1564,6 +1566,96 @@ class CloneForm(BaseModel):
title: str | None = None
class ForkForm(BaseModel):
message_id: str | None = None
@router.post('/{id}/fork', response_model=ChatResponse | None)
async def fork_chat_by_id(
request: Request,
id: str,
form_data: ForkForm | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
if await has_active_tasks(request.app.state.redis, id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
if any(
message.get('role') == 'assistant' and message.get('done') is False
for message in messages_map.values()
if isinstance(message, dict)
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
source_message_id = (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
if not source_message_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
try:
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
except ValueError as exc:
detail = str(exc)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
detail=detail,
) from exc
updated_chat = {**(chat.chat or {})}
updated_chat.pop('currentId', None)
updated_chat.update(
{
'originalChatId': chat.id,
'branchPointMessageId': source_message_id,
'title': f'{chat.title} (fork)',
'history': fork_history,
'messages': fork_messages,
}
)
meta = {
**(chat.meta or {}),
'forked_from': chat.id,
'forked_from_message_id': source_message_id,
}
fork = await Chats.insert_new_chat(
str(uuid4()),
user.id,
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
db=db,
internal_meta=meta,
)
if not fork:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
if chat.pinned:
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
await publish_event(
request,
EVENTS.CHAT_CLONED,
actor=user,
subject_id=fork.id,
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
)
return ChatResponse(**fork.model_dump())
@router.post('/{id}/clone', response_model=ChatResponse | None)
async def clone_chat_by_id(
request: Request,

View file

@ -6,7 +6,7 @@ import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
@ -482,6 +482,9 @@ async def update_folder_access_by_id(
async def get_shared_folder_chats(
request: Request,
id: str,
page: int | None = Query(None, ge=1),
sort_by: str = Query('updated_at'),
sort_dir: str = Query('desc'),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -505,7 +508,17 @@ async def get_shared_folder_chats(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
limit = 10
skip = (page - 1) * limit if page is not None else 0
chats = await Chats.get_all_chats_by_folder_id(
id,
skip=skip,
limit=limit if page is not None else 60,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
# Resolve owner names for display (avatar URLs are constructed client-side)
owner_cache: dict[str, str] = {}
@ -516,10 +529,13 @@ async def get_shared_folder_chats(
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
return {
response = {
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
'folder_permission': 'write' if has_write else 'read',
}
if page is not None:
response.update({'total': total, 'has_more': skip + limit < total})
return response
############################

View file

@ -785,7 +785,11 @@ async def image_generations(
images.append({'url': url})
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
if form_data.model:
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
# persists the global default and switches the shared backend. Only an
# admin may do that; a non-admin generates on the currently configured
# checkpoint. The model field is not a per-user selection on this backend.
if form_data.model and user.role == 'admin':
await set_image_model(request, form_data.model)
data = {

View file

@ -1180,6 +1180,14 @@ class OpenAICompletionForm(BaseModel):
model_config = ConfigDict(extra='allow')
class OpenAIEmbeddingsForm(BaseModel):
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
model: str
input: object
model_config = ConfigDict(extra='allow')
@router.post('/v1/completions')
@router.post('/v1/completions/{url_idx}')
async def generate_openai_completion(
@ -1235,6 +1243,56 @@ async def generate_openai_completion(
)
@router.post('/v1/embeddings')
@router.post('/v1/embeddings/{url_idx}')
async def generate_openai_embeddings(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward an embeddings request via the OpenAI-compatible proxy."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
metadata = form_data.pop('metadata', None)
try:
form_data = OpenAIEmbeddingsForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True)}
payload.pop('metadata', None)
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/embeddings',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
metadata=metadata,
api_config=api_config,
request=request,
)
@router.post('/v1/chat/completions')
@router.post('/v1/chat/completions/{url_idx}')
async def generate_openai_chat_completion(

View file

@ -200,6 +200,7 @@ class SharingPermissions(BaseModel):
public_skills: bool = False
notes: bool = False
public_notes: bool = True
folders: bool = False
public_chats: bool = False
public_calendars: bool = False
@ -475,6 +476,23 @@ async def update_user_settings_by_session_user(
# If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings['ui'].pop('toolServers', None)
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
if (
user.role != 'admin'
and (
'notifications' in updated_user_settings
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
)
and not await has_permission(
user.id,
'features.webhooks',
await Config.get('user.permissions'),
)
):
updated_user_settings.pop('notifications', None)
if isinstance(ui_notifications, dict):
ui_notifications.pop('webhook_url', None)
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user:
await publish_event(

View file

@ -57,6 +57,29 @@ REDIS = None
# Configure CORS for Socket.IO
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
Every instance subscribed to the shared Socket.IO channel receives every
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
the full packet before discovering the target room has no participants on
this instance, so each instance burns CPU serializing payloads addressed
to sessions it does not host — a cost that grows with instance count.
Bail out before that work when the room is empty here. Only string
rooms take the fast path; broadcasts (``room=None``) and any other
room shape (e.g. lists, which upstream indexes before validating)
always pass through unchanged.
"""
async def _handle_emit(self, message):
room = message.get('room')
if isinstance(room, str):
namespace = message.get('namespace') or '/'
if next(self.get_participants(namespace, room), None) is None:
return
await super()._handle_emit(message)
if WEBSOCKET_MANAGER == 'redis':
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
ws_redis_url = (
@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis':
if sentinel_hosts
else WEBSOCKET_REDIS_URL
)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',

View file

@ -686,12 +686,12 @@ async def _kb_grep(
# Grep on piped input
if piped_input is not None:
lines = piped_input.split('\\n')
lines = piped_input.split('\n')
matched = []
for i, line in enumerate(lines, 1):
if _matches(line):
matched.append(f'{i}: {line}')
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
# Single file grep
if file_ref and not dir_scope:
@ -702,7 +702,7 @@ async def _kb_grep(
elif 'error' in resolved:
return resolved['error']
else:
lines = resolved['content'].split('\\n')
lines = resolved['content'].split('\n')
matched = []
for i, line in enumerate(lines, 1):
if _matches(line):
@ -715,7 +715,7 @@ async def _kb_grep(
if not matched:
return f'No matches for "{pattern}" in {resolved["filename"]}'
return '\\n'.join(matched)
return '\n'.join(matched)
# Cross-file grep (optionally scoped to directory)
accessible = await _get_accessible_files(user, model_knowledge)

View file

@ -9,7 +9,7 @@ from open_webui.models.functions import Functions
from open_webui.models.users import UserModel
from open_webui.socket.main import get_event_call, get_event_emitter
from open_webui.utils.middleware import process_tool_result
from open_webui.utils.models import get_all_models
from open_webui.utils.models import check_model_access, get_all_models
from open_webui.utils.plugin import get_function_module_from_cache
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@ -46,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
raise Exception('Model not found')
model = models[model_id]
# Availability gate — keep this route consistent with the actions a model
# actually surfaces to the client. Executing admin-authored Function code is
# intended; this only stops a disabled, unassigned, or access-restricted
# action from being reached by calling the route with a raw action_id.
if action.type != 'action' or not action.is_active:
raise Exception(f'Action not available: {action_id}')
# Direct connections carry a client-supplied model the caller already owns,
# so scope the model-bound checks to server-resolved models.
if not getattr(request.state, 'direct', False) and user.role != 'admin':
await check_model_access(user, model)
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
# the function id is always the prefix.
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
if action_id not in surfaced_action_ids:
raise Exception(f'Action not available: {action_id}')
__event_emitter__ = await get_event_emitter(
{
'chat_id': data['chat_id'],

View file

@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
'model',
'messages',
'system',
'max_tokens',
'temperature',
'top_p',
'top_k',
'stop_sequences',
'stream',
'metadata',
'service_tier',
'tools',
'tool_choice',
'reasoning_effort',
}
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
@ -154,7 +171,9 @@ def _finalize_openai_content(blocks: list) -> str | list:
return blocks
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
def convert_anthropic_to_openai_payload(
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
@ -395,6 +414,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if 'max_tokens' in anthropic_payload:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
captured_passthrough_params = {
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
}
if isinstance(passthrough_params, str):
passthrough_params = passthrough_params.split(',')
elif not isinstance(passthrough_params, (list, tuple, set)):
passthrough_params = []
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
if '*' in passthrough_param_names:
openai_payload.update(captured_passthrough_params)
else:
for param in passthrough_param_names:
if param in captured_passthrough_params:
openai_payload[param] = captured_passthrough_params[param]
output_config = anthropic_payload.get('output_config')
if isinstance(output_config, dict):
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
openai_payload['reasoning_effort'] = output_config['effort']
format_config = output_config.get('format')
if isinstance(format_config, dict):
format_type = format_config.get('type')
if format_type == 'json_schema':
json_schema = {
'name': format_config.get('name', 'response_schema'),
'schema': format_config.get('schema', {}),
}
if 'description' in format_config:
json_schema['description'] = format_config['description']
if 'strict' in format_config:
json_schema['strict'] = format_config['strict']
openai_payload['response_format'] = {
'type': 'json_schema',
'json_schema': json_schema,
}
elif format_type == 'json_object':
openai_payload['response_format'] = {'type': format_type}
if 'reasoning_effort' in anthropic_payload:
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
# Common parameters
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
if param in anthropic_payload:
@ -471,6 +532,32 @@ def convert_openai_to_anthropic_response(
# Build content blocks
content = []
message_thinking = message.get('thinking')
thinking_blocks = message.get('thinking_blocks') or []
if not thinking_blocks and isinstance(message_thinking, dict):
thinking_blocks = message_thinking.get('blocks') or []
has_thinking = False
for block in thinking_blocks:
if not isinstance(block, dict):
continue
thinking = block.get('thinking') or block.get('content') or block.get('text')
if not thinking:
continue
thinking_block = {'type': 'thinking', 'thinking': thinking}
if block.get('signature'):
thinking_block['signature'] = block['signature']
content.append(thinking_block)
has_thinking = True
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
if not reasoning_content and isinstance(message_thinking, str):
reasoning_content = message_thinking
if reasoning_content and not has_thinking:
content.append({'type': 'thinking', 'thinking': reasoning_content})
message_content = message.get('content')
if message_content:
content.append({'type': 'text', 'text': message_content})
@ -560,6 +647,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
thinking_block_open = False
text_block_open = False
# Accumulated state for each tool call, keyed by tool call id.
@ -623,11 +711,52 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
# Update usage if present
capture_usage(data.get('usage'))
reasoning_content = (
delta.get('reasoning_content')
or delta.get('reasoning')
or delta.get('thinking')
or message.get('reasoning_content')
or message.get('reasoning')
)
if not reasoning_content:
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
for block in thinking_blocks:
if isinstance(block, dict):
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
if reasoning_content:
break
if reasoning_content and not text_block_open and not has_tool_calls:
if not thinking_block_open:
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {'type': 'thinking', 'thinking': ''},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
thinking_block_open = True
block_delta = {
'type': 'content_block_delta',
'index': current_block_index,
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle text content ---
# Anthropic expects text blocks before tool blocks, so skip
# text deltas once any tool call has started.
content = delta.get('content')
if content and not has_tool_calls:
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if not text_block_open:
block_start = {
'type': 'content_block_start',
@ -653,6 +782,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if tool_calls:
# Close text block if one is open (text comes before tools)
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if text_block_open:
block_stop = {
'type': 'content_block_stop',
@ -764,6 +902,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except Exception as e:
log.error(f'Error in Anthropic stream conversion: {e}')
# Close any open thinking block
if thinking_block_open:
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
current_block_index += 1
# Flush any tools that buffered arguments but never emitted a block
for tool in tracked_tool_calls.values():
if not tool['started'] and tool['name']:

View file

@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.env import CUSTOM_API_KEY_HEADER
from open_webui.internal.db import ScopedSession
from open_webui.models.config import Config
from open_webui.utils.auth import get_http_authorization_cred
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
@ -138,9 +137,7 @@ class AuthTokenMiddleware:
the middleware checks that instead and avoids the 401 short-circuit.
Routes that depend on `get_verified_user` etc. read this state.
Also exposes `request.state.enable_api_keys` (snapshotted at request
entry from runtime config) and stamps an `X-Process-Time` response
header.
Also stamps an `X-Process-Time` response header.
"""
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
@ -166,7 +163,6 @@ class AuthTokenMiddleware:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
request.state.token = token
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
async def send_with_timing(message: Message) -> None:
if message['type'] == 'http.response.start':
@ -231,7 +227,15 @@ class RedirectMiddleware:
return
path = scope.get('path', '')
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
raw_query = scope.get('query_string', b'')
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
# decode + parse_qs work for every other GET. (A false positive on the
# substring check just falls through to the full parse below.)
if not (path.endswith('/watch') or b'shared' in raw_query):
await self.app(scope, receive, send)
return
query_string = raw_query.decode('latin-1', errors='replace')
query_params = parse_qs(query_string)
redirect_params: dict[str, str] = {}

View file

@ -437,24 +437,24 @@ async def get_current_user_by_api_key(request, api_key: str):
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
user_permissions = await Config.get('user.permissions')
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
if not await Config.get('auth.enable_api_keys'):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
if not request.state.enable_api_keys or (
user.role != 'admin'
and not await has_permission(
if user.role != 'admin':
user_permissions = await Config.get('user.permissions')
if not await has_permission(
user.id,
'features.api_keys',
user_permissions,
)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
# Enforce endpoint restrictions — checked here (not in middleware)
# so it applies regardless of how the API key was transported
# (Authorization header, cookie, x-api-key header, etc.).
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
if enable_endpoint_restrictions:
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)

View file

@ -0,0 +1,41 @@
from copy import deepcopy
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
if not messages_map:
raise ValueError('chat has no messages to fork')
branch: list[tuple[str, dict]] = []
seen: set[str] = set()
message_id = source_message_id
while message_id:
if message_id in seen:
raise ValueError('message branch contains a cycle')
seen.add(message_id)
message = messages_map.get(message_id)
if not isinstance(message, dict):
raise ValueError('message not found')
branch.append((message_id, message))
message_id = message.get('parentId')
fork_messages: dict[str, dict] = {}
ordered_messages: list[dict] = []
parent_id = None
for message_id, message in reversed(branch):
copied = deepcopy(message)
copied['id'] = message_id
copied['parentId'] = parent_id
copied['childrenIds'] = []
if parent_id:
fork_messages[parent_id]['childrenIds'] = [message_id]
fork_messages[message_id] = copied
ordered_messages.append(copied)
parent_id = message_id
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages

View file

@ -148,8 +148,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
if not config['enable']:
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
history = (chat.chat or {}).get('history') or {}
current_id = history.get('currentId')
chat_data = chat.chat or {}
history = chat_data.get('history') or {}
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
if not current_id:
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
current_id = chat_data['messages'][-1].get('id')
if not current_id:
return {'ok': True, 'compacted': False, 'reason': 'empty'}
@ -216,8 +221,13 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
history = (chat.chat or {}).get('history') or {}
current_id = history.get('currentId')
chat_data = chat.chat or {}
history = chat_data.get('history') or {}
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
if not current_id:
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
current_id = chat_data['messages'][-1].get('id')
if not current_id:
return None

View file

@ -4490,7 +4490,12 @@ async def streaming_chat_response_handler(response, ctx):
user,
)
content = f'{content}{value}'
if isinstance(value, str):
# In-place append — avoids copying the full
# accumulated response on every chunk.
content += value
else:
content = f'{content}{value}'
# Check if we're inside a tag-based block
# (reasoning, code_interpreter, or solution).
@ -4597,16 +4602,17 @@ async def streaming_chat_response_handler(response, ctx):
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith(
'channel:'
):
current_output = full_output()
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'output': full_output(),
'output': current_output,
},
)
data = {
'output': full_output(),
'output': current_output,
}
delta_type = 'content'
else:

View file

@ -68,6 +68,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'models.base_models_cache',
'evaluation.arena.enable',
'evaluation.arena.models',
'models.default_metadata',
)
if (
request.app.state.MODELS
@ -125,23 +126,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
]
models = models + arena_models
global_action_ids = (
{function.id for function in await Functions.get_global_action_functions()} if ENABLE_PLUGINS else set()
)
enabled_action_ids = (
{function.id for function in await Functions.get_functions_by_type('action', active_only=True)}
if ENABLE_PLUGINS
else set()
)
# One query per type: the global sets are subsets of the active sets, so
# deriving them from the same rows halves the function-table queries.
if ENABLE_PLUGINS:
active_actions = await Functions.get_functions_by_type('action', active_only=True)
global_action_ids = {function.id for function in active_actions if function.is_global}
enabled_action_ids = {function.id for function in active_actions}
global_filter_ids = (
{function.id for function in await Functions.get_global_filter_functions()} if ENABLE_PLUGINS else set()
)
enabled_filter_ids = (
{function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
if ENABLE_PLUGINS
else set()
)
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
global_filter_ids = {function.id for function in active_filters if function.is_global}
enabled_filter_ids = {function.id for function in active_filters}
else:
global_action_ids = set()
enabled_action_ids = set()
global_filter_ids = set()
enabled_filter_ids = set()
custom_models = await Models.get_all_models()
@ -305,7 +304,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = await Config.get('models.default_metadata', {}) or {}
default_metadata = config.get('models.default_metadata') or {}
if default_metadata:
for model in models:

View file

@ -15,16 +15,19 @@ class SecurityHeadersMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
# Headers derive only from env vars, which are static for the process
# lifetime — compute them once instead of per response.
self._headers = list(set_security_headers().items())
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http':
if scope['type'] != 'http' or not self._headers:
await self.app(scope, receive, send)
return
async def send_with_security_headers(message: Message) -> None:
if message['type'] == 'http.response.start':
headers = MutableHeaders(scope=message)
for key, value in set_security_headers().items():
for key, value in self._headers:
headers[key] = value
await send(message)

View file

@ -33,6 +33,7 @@ alembic==1.18.4
pycrdt==0.13.1
redis
hiredis
APScheduler==3.11.2
RestrictedPython==8.2

View file

@ -30,7 +30,8 @@ psycopg[binary]==3.3.4
alembic==1.18.4
pycrdt==0.13.1
redis==8.0.0
redis==8.0.1
hiredis==3.4.0
APScheduler==3.11.2
RestrictedPython==8.2

View file

@ -38,7 +38,8 @@ dependencies = [
"alembic==1.18.4",
"pycrdt==0.13.1",
"redis==8.0.0",
"redis==8.0.1",
"hiredis==3.4.0",
# "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey
"pytz==2026.2",
@ -151,7 +152,6 @@ all = [
"psycopg2-binary==2.9.12",
"pgvector==0.4.2",
"moto[s3]>=5.0.26",
"gcp-storage-emulator>=2024.8.3",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.2.5",

View file

@ -920,6 +920,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
return res;
};
export const forkChatById = async (token: string, id: string, messageId?: string | null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
message_id: messageId ?? null
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const cloneSharedChatById = async (token: string, id: string) => {
let error = null;

View file

@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => {
return res;
};
export const getSharedFolderChats = async (token: string, folderId: string) => {
export const getSharedFolderChats = async (
token: string,
folderId: string,
params: {
page?: number | null;
sortBy?: 'title' | 'updated_at';
sortDir?: 'asc' | 'desc';
} = {}
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
const searchParams = new URLSearchParams();
if (params.page !== undefined && params.page !== null) {
searchParams.append('page', `${params.page}`);
}
if (params.sortBy) {
searchParams.append('sort_by', params.sortBy);
}
if (params.sortDir) {
searchParams.append('sort_dir', params.sortDir);
}
const query = searchParams.toString();
const res = await fetch(
`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`,
{
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();

View file

@ -49,6 +49,7 @@
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
let headers = '';
let passthroughParams = '';
let tags = [];
@ -57,12 +58,19 @@
let loading = false;
let showDeleteConfirmDialog = false;
let showAdvanced = false;
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const parsePassthroughParams = (value: string) =>
value
.split(',')
.map((param) => param.trim())
.filter(Boolean);
const verifyOllamaHandler = async () => {
// remove trailing slash from url
url = url.replace(/\/$/, '');
@ -109,6 +117,7 @@
...(provider ? { provider } : {}),
...(azure ? { azure: true } : {}),
api_version: apiVersion,
passthrough_params: parsePassthroughParams(passthroughParams),
...(_headers ? { headers: _headers } : {})
}
},
@ -149,6 +158,7 @@
if (azure) {
if (!apiVersion) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('API Version is required'));
return;
@ -163,6 +173,7 @@
if (modelIds.length === 0) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
return;
}
@ -195,6 +206,7 @@
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
passthrough_params: parsePassthroughParams(passthroughParams),
...(provider ? { provider } : {}),
...(!ollama && azure ? { azure: true } : {}),
...(azure ? { api_version: apiVersion } : {}),
@ -211,6 +223,8 @@
key = '';
auth_type = 'bearer';
prefixId = '';
passthroughParams = '';
showAdvanced = false;
tags = [];
modelIds = [];
};
@ -228,6 +242,9 @@
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
passthroughParams = Array.isArray(connection.config?.passthrough_params)
? connection.config.passthrough_params.join(', ')
: (connection.config?.passthrough_params ?? '');
modelIds = connection.config?.model_ids ?? [];
if (ollama) {
@ -429,107 +446,6 @@
</div>
</div>
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-1">
<label
@ -548,19 +464,7 @@
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if apiType === 'responses'}
<Tooltip
className="flex items-center gap-1"
content={$i18n.t(
'This feature is currently experimental and may not work as expected.'
)}
>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
{$i18n.t('Responses')}
</Tooltip>
{$i18n.t('Responses')}
{:else}
{$i18n.t('Chat Completions')}
{/if}
@ -569,111 +473,268 @@
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
{$i18n.t('Model IDs')}
</div>
</div>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
{#if showAdvanced}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
</div>
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
{#if !ollama && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="allowed-passthrough-params-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Passthrough params')}</label
>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="allowed-passthrough-params-input"
bind:value={passthroughParams}
placeholder={$i18n.t('thinking, output_config')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
>
{$i18n.t('Model IDs')}
</div>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t(
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
{
url: url
}
)}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>
{/if}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium">

View file

@ -33,13 +33,14 @@
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ModelSettingsModal from './Models/ModelSettingsModal.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelDefaultsPanel from './Models/ModelDefaultsPanel.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@ -47,7 +48,10 @@
import Download from '$lib/components/icons/Download.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import SettingsIcon from '$lib/components/icons/Settings.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { goto } from '$app/navigation';
@ -83,11 +87,13 @@
let filteredModels = [];
let selectedModelId = null;
let showConfigModal = false;
let showManageModal = false;
let showResetModal = false;
let savingModelOrder = false;
let savingModelsSettings = false;
let modelOrderDirty = false;
let modelDefaultsPanel = null;
let modelDefaultsDirty = false;
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
let tags: string[] = [];
@ -322,6 +328,20 @@
savingModelOrder = false;
};
const saveModelsSettings = async () => {
savingModelsSettings = true;
if (modelOrderDirty) {
await saveModelOrder(modelOrderList);
}
if (modelDefaultsDirty) {
await modelDefaultsPanel?.save();
}
savingModelsSettings = false;
};
const saveModelDefaults = async (
nextDefaultModelIds: string[],
nextDefaultPinnedModelIds: string[],
@ -649,7 +669,6 @@
}}
/>
<ModelSettingsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null}
@ -662,18 +681,6 @@
{filteredModels.length}
</span>
</h2>
<Tooltip content={$i18n.t('Settings')}>
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => {
showConfigModal = true;
}}
>
<SettingsIcon className="size-3.5" />
</button>
</Tooltip>
</div>
{#if $user?.role === 'admin'}
@ -714,6 +721,12 @@
{/if}
<div class="flex min-h-0 flex-1 flex-col space-y-1">
<ModelDefaultsPanel
bind:this={modelDefaultsPanel}
bind:dirty={modelDefaultsDirty}
initHandler={init}
/>
<div class="flex h-8 shrink-0 items-center w-full gap-2">
<div class="flex min-w-0 flex-1 items-center">
<div class=" self-center ml-1 mr-3">
@ -1005,6 +1018,79 @@
{/if}
</button>
</Tooltip>
<Tooltip
content={defaultModelIdSet.has(model.id)
? $i18n.t('Remove Selected Model')
: $i18n.t('Set as Selected Model')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultModelIdSet.has(
model.id
)
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
type="button"
aria-label={defaultModelIdSet.has(model.id)
? $i18n.t('Remove Selected Model')
: $i18n.t('Set as Selected Model')}
on:click={() => {
toggleDefaultModelHandler(model);
}}
>
<Check className="size-3.5" />
</button>
</Tooltip>
<Tooltip
content={defaultPinnedModelIdSet.has(model.id)
? $i18n.t('Remove Pinned Model')
: $i18n.t('Set as Pinned Model')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultPinnedModelIdSet.has(
model.id
)
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
type="button"
aria-label={defaultPinnedModelIdSet.has(model.id)
? $i18n.t('Remove Pinned Model')
: $i18n.t('Set as Pinned Model')}
on:click={() => {
toggleDefaultPinnedModelHandler(model);
}}
>
{#if defaultPinnedModelIdSet.has(model.id)}
<PinSlash className="size-3.5" />
{:else}
<Pin className="size-3.5" />
{/if}
</button>
</Tooltip>
<Tooltip
content={isPublicModel(model)
? $i18n.t('Make Private')
: $i18n.t('Make Public')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl text-gray-500 hover:bg-black/5 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
aria-label={isPublicModel(model)
? $i18n.t('Make Private')
: $i18n.t('Make Public')}
on:click={() => {
toggleModelPrivacyHandler(model);
}}
>
{#if isPublicModel(model)}
<LockClosed className="size-3.5" />
{:else}
<GlobeAlt className="size-3.5" />
{/if}
</button>
</Tooltip>
{/if}
<button
@ -1105,13 +1191,15 @@
<button
class="flex items-center gap-2 px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full disabled:cursor-not-allowed disabled:opacity-40"
type="button"
disabled={!modelOrderDirty || savingModelOrder}
disabled={(!modelOrderDirty && !modelDefaultsDirty) ||
savingModelOrder ||
savingModelsSettings}
on:click={async () => {
await saveModelOrder(modelOrderList);
await saveModelsSettings();
}}
>
{$i18n.t('Save')}
{#if savingModelOrder}
{#if savingModelOrder || savingModelsSettings}
<span class="shrink-0">
<Spinner />
</span>

View file

@ -0,0 +1,242 @@
<script>
import { getContext, onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
import { config as appConfig } from '$lib/stores';
import { DEFAULT_CAPABILITIES } from '$lib/constants';
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
import { getBackendConfig } from '$lib/apis';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
export let initHandler = () => {};
export let dirty = false;
let config = null;
let modelIds = [];
let loading = false;
let expanded = false;
let showCapabilities = false;
let showParameters = false;
let showPromptSuggestions = false;
let savedSnapshot = '';
let defaultCapabilities = {};
let defaultFeatureIds = [];
let defaultParams = {};
let builtinTools = {};
let promptSuggestions = [];
$: configuredParams = Object.entries(defaultParams ?? {}).filter(
([_, value]) => value !== null && value !== '' && value !== undefined
);
$: enabledCapabilities = Object.entries(defaultCapabilities ?? {}).filter(([_, value]) => value);
$: availableFeatures = enabledCapabilities
.filter(([key]) => ['web_search', 'code_interpreter', 'image_generation'].includes(key))
.map(([key]) => key);
const getSnapshot = () =>
JSON.stringify({
defaultCapabilities,
defaultFeatureIds,
defaultParams: Object.fromEntries(configuredParams),
builtinTools,
promptSuggestions: promptSuggestions.filter((p) => p.content !== '')
});
const updateDirty = async () => {
await tick();
dirty = savedSnapshot !== '' && getSnapshot() !== savedSnapshot;
};
const init = async () => {
loading = true;
config = await getModelsConfig(localStorage.token);
modelIds = config?.MODEL_ORDER_LIST || [];
const savedMeta = config?.DEFAULT_MODEL_METADATA;
if (savedMeta && Object.keys(savedMeta).length > 0) {
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
builtinTools = savedMeta.builtinTools ?? {};
} else {
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = [];
builtinTools = {};
}
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
promptSuggestions = $appConfig?.default_prompt_suggestions ?? [];
savedSnapshot = getSnapshot();
dirty = false;
loading = false;
};
export const save = async () => {
if (loading || !dirty) {
return true;
}
const metadata = {
capabilities: defaultCapabilities,
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
};
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: config?.DEFAULT_MODELS ?? null,
DEFAULT_PINNED_MODELS: config?.DEFAULT_PINNED_MODELS ?? null,
MODEL_ORDER_LIST: modelIds,
DEFAULT_MODEL_METADATA: metadata,
DEFAULT_MODEL_PARAMS: Object.fromEntries(configuredParams)
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
config = res;
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await appConfig.set(await getBackendConfig());
savedSnapshot = getSnapshot();
dirty = false;
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
return true;
} else {
toast.error($i18n.t('Failed to save models configuration'));
return false;
}
};
onMount(async () => {
await init();
});
</script>
<div class="shrink-0">
<div class="flex items-center justify-between gap-4 py-0.5">
<button
class="min-w-0 flex-1 text-left text-xs text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
type="button"
on:click={() => {
expanded = !expanded;
}}
>
{$i18n.t('Model Defaults')}
</button>
<button
class="shrink-0 text-[0.6875rem] text-gray-400 transition hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
expanded = !expanded;
}}
>
{expanded ? $i18n.t('Close') : $i18n.t('Configure')}
</button>
</div>
{#if expanded}
{#if loading}
<div class="py-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</div>
{:else}
<div class="space-y-1 mt-0.5">
<div>
<button
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
type="button"
on:click={() => {
showCapabilities = !showCapabilities;
}}
>
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Model Capabilities')}
</span>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{showCapabilities ? $i18n.t('Close') : $i18n.t('Configure')}
</span>
</button>
{#if showCapabilities}
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
<Capabilities bind:capabilities={defaultCapabilities} />
{#if availableFeatures.length > 0}
<div class="mt-4">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{/if}
{#if defaultCapabilities.builtin_tools}
<div class="mt-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
</div>
{/if}
</div>
<div>
<button
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
type="button"
on:click={() => {
showParameters = !showParameters;
}}
>
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Model Parameters')}
</span>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{showParameters ? $i18n.t('Close') : $i18n.t('Configure')}
</span>
</button>
{#if showParameters}
<div
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
on:click={updateDirty}
on:change={updateDirty}
on:input={updateDirty}
>
<AdvancedParams admin={true} custom={true} bind:params={defaultParams} />
</div>
{/if}
</div>
<div>
<button
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
type="button"
on:click={() => {
showPromptSuggestions = !showPromptSuggestions;
}}
>
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Prompt Suggestions')}
</span>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{showPromptSuggestions ? $i18n.t('Close') : $i18n.t('Configure')}
</span>
</button>
{#if showPromptSuggestions}
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
<PromptSuggestions bind:promptSuggestions />
</div>
{/if}
</div>
</div>
{/if}
{/if}
</div>

View file

@ -1,295 +0,0 @@
<script>
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { config as _config } from '$lib/stores';
import { DEFAULT_CAPABILITIES } from '$lib/constants';
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
import { getBackendConfig } from '$lib/apis';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let modelIds = [];
let loading = false;
let showDefaultCapabilities = false;
let showDefaultParams = false;
let showDefaultPromptSuggestions = false;
let defaultCapabilities = {};
let defaultFeatureIds = [];
let defaultParams = {};
let builtinTools = {};
let promptSuggestions = [];
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
modelIds = config.MODEL_ORDER_LIST || [];
const savedMeta = config?.DEFAULT_MODEL_METADATA;
if (savedMeta && Object.keys(savedMeta).length > 0) {
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
builtinTools = savedMeta.builtinTools ?? {};
} else {
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = [];
builtinTools = {};
}
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
promptSuggestions = $_config?.default_prompt_suggestions ?? [];
};
const submitHandler = async () => {
loading = true;
const metadata = {
capabilities: defaultCapabilities,
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
};
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: config?.DEFAULT_MODELS ?? null,
DEFAULT_PINNED_MODELS: config?.DEFAULT_PINNED_MODELS ?? null,
MODEL_ORDER_LIST: modelIds,
DEFAULT_MODEL_METADATA: metadata,
DEFAULT_MODEL_PARAMS: Object.fromEntries(
Object.entries(defaultParams).filter(([_, v]) => v !== null && v !== '' && v !== undefined)
)
});
if (res) {
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await _config.set(await getBackendConfig());
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<Modal size="lg" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">
{$i18n.t('Settings')}
</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={() => {
show = false;
}}
>
<XMark className={'size-4'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition"
type="button"
>
<div class=" self-center mr-2">
<AdjustmentsHorizontal />
</div>
<div class=" self-center">{$i18n.t('Defaults')}</div>
</button>
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col min-w-0">
<div class="w-full h-full overflow-y-auto overflow-x-hidden scrollbar-hidden">
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultPromptSuggestions = !showDefaultPromptSuggestions;
}}
>
<div class="text-xs text-gray-500 font-normal">
{$i18n.t('Prompt Suggestions')}
</div>
<div>
{#if showDefaultPromptSuggestions}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultPromptSuggestions}
<div class="mt-2">
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2 text-gray-500">
{$i18n.t(
'Adjusting these settings will apply changes universally to all users.'
)}
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultCapabilities = !showDefaultCapabilities;
}}
>
<div class="text-xs text-gray-500 font-normal">
{$i18n.t('Model Capabilities')}
</div>
<div>
{#if showDefaultCapabilities}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultCapabilities}
<div class="mt-2">
<Capabilities bind:capabilities={defaultCapabilities} />
{#if Object.keys(defaultCapabilities).filter((key) => defaultCapabilities[key]).length > 0}
{@const availableFeatures = Object.entries(defaultCapabilities)
.filter(
([key, value]) =>
value &&
['web_search', 'code_interpreter', 'image_generation'].includes(key)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="mt-4">
<DefaultFeatures
{availableFeatures}
bind:featureIds={defaultFeatureIds}
/>
</div>
{/if}
{/if}
{#if defaultCapabilities.builtin_tools}
<div class="mt-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultParams = !showDefaultParams;
}}
>
<div class="text-xs text-gray-500 font-normal">
{$i18n.t('Model Parameters')}
</div>
<div>
{#if showDefaultParams}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultParams}
<div class="mt-2">
<AdvancedParams admin={true} custom={true} bind:params={defaultParams} />
</div>
{/if}
</div>
</div>
<div class="flex justify-end items-center pt-3 text-sm font-normal gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<span class="shrink-0">
<Spinner />
</span>
{/if}
</button>
</div>
</div>
</div>
</form>
{:else}
<div>
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -70,10 +70,10 @@
import {
archiveChatById,
cloneChatById,
compactChatById,
createNewChat,
deleteChatById,
forkChatById,
getAllTags,
getChatById,
getTagsById,
@ -1177,7 +1177,7 @@
}
const pageSubscribe = page.subscribe(async (p) => {
if (p.url.pathname === '/') {
if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) {
await tick();
initNewChat();
}
@ -1884,6 +1884,9 @@
(chatContent?.history ?? undefined) !== undefined
? chatContent.history
: convertMessagesToHistory(chatContent.messages);
if (chat?.current_message_id && history?.messages?.[chat.current_message_id]) {
history.currentId = chat.current_message_id;
}
// Sanitize history: repair orphaned references and structurally-malformed
// nodes from failed regenerations (#24424, #24157, #20474)
@ -2466,7 +2469,7 @@
document.getElementById('chat-input')?.focus();
};
const handleForkChat = async () => {
const handleForkChat = async (messageId: string | null = null) => {
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to fork'));
return;
@ -2489,13 +2492,7 @@
const toastId = toast.loading($i18n.t('Forking chat...'));
try {
const result = await cloneChatById(
localStorage.token,
$chatId,
$i18n.t('Clone of {{TITLE}}', {
TITLE: $chatTitle || 'Chat'
})
);
const result = await forkChatById(localStorage.token, $chatId, messageId ?? history.currentId);
if (result?.id) {
if (!embedded) {
@ -3725,6 +3722,7 @@
{mergeResponses}
{chatActionHandler}
{addMessages}
forkHandler={generating || taskIds?.length ? null : handleForkChat}
topPadding={!embedded}
bottomPadding={files.length > 0}
{onSelect}

View file

@ -183,10 +183,10 @@
</button>
</Tooltip>
{:else if item.data.id === 'fork'}
<Tooltip content="Clone this chat into a new copy." placement="top">
<Tooltip content="Fork the current chat branch into a new chat." placement="top">
<button
type="button"
aria-label="Fork: clone this chat into a new copy."
aria-label="Fork: fork the current chat branch into a new chat."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''} disabled:opacity-50"
disabled={forkDisabled}
@ -222,7 +222,7 @@
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Fork</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">Clone chat</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">Current branch</span>
</span>
</button>
</Tooltip>

View file

@ -40,13 +40,16 @@
export let showMessage: Function = () => {};
export let submitMessage: Function = () => {};
export let addMessages: Function = () => {};
export let forkHandler: Function | null = null;
export let readOnly = false;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let bottomPadding = false;
export let autoScroll;
export let messagesContainerId = 'messages-container';
export let onSelect = (e) => {};
export let onInsertToNote: ((content: string) => void) | null = null;
@ -54,14 +57,18 @@
export let messagesCount: number | null = 8;
let messagesLoading = false;
const getMessagesContainer = () => document.getElementById(messagesContainerId);
onDestroy(() => {
cancelAnimationFrame(pendingRebuild);
});
const loadMoreMessages = async () => {
// scroll slightly down to disable continuous loading
const element = document.getElementById('messages-container');
element.scrollTop = element.scrollTop + 100;
const element = getMessagesContainer();
if (element) {
element.scrollTop = element.scrollTop + 100;
}
messagesLoading = true;
messagesCount += 8;
@ -133,7 +140,7 @@
}
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
const element = getMessagesContainer();
if (element) {
element.scrollTop = element.scrollHeight;
@ -213,8 +220,10 @@
// Optional auto-scroll
if ($settings?.scrollOnBranchChange ?? true) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
const element = getMessagesContainer();
autoScroll = element
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
: false;
setTimeout(() => {
scrollToBottom();
@ -260,8 +269,10 @@
await tick();
if ($settings?.scrollOnBranchChange ?? true) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
const element = getMessagesContainer();
autoScroll = element
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
: false;
setTimeout(() => {
scrollToBottom();
@ -311,8 +322,10 @@
await tick();
if ($settings?.scrollOnBranchChange ?? true) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
const element = getMessagesContainer();
autoScroll = element
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
: false;
setTimeout(() => {
scrollToBottom();
@ -483,11 +496,13 @@
const triggerScroll = () => {
if (autoScroll) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
setTimeout(() => {
scrollToBottom();
}, 100);
const element = getMessagesContainer();
if (element) {
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
setTimeout(() => {
scrollToBottom();
}, 100);
}
}
};
</script>
@ -539,8 +554,10 @@
{continueResponse}
{mergeResponses}
{addMessages}
{forkHandler}
{triggerScroll}
{readOnly}
{preview}
{editCodeBlock}
{topPadding}
{onInsertToNote}

View file

@ -307,7 +307,7 @@
{#if extracted.detailsContent}
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
<div class="markdown-prose">
<Markdown {id} content={extracted.detailsContent} {done} />
<Markdown {id} content={extracted.detailsContent} {preview} {done} />
</div>
{/if}
{#if extracted.plainContent}

View file

@ -31,6 +31,7 @@
export let messageDone = true;
export let allowEmbeds = true;
export let preview = false;
let open = $settings?.expandDetails ?? false;
@ -112,7 +113,9 @@
<div {id} class="w-full">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<button
class="w-fit py-0.5 text-left text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
class="w-fit py-0.5 text-left {preview
? 'text-xs'
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
aria-label={$i18n.t('Toggle details')}
aria-expanded={open}
on:click={() => {

View file

@ -97,6 +97,10 @@
.trim();
};
$: detailButtonClassName = `w-fit py-0.5 ${
preview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
$: displayTokens = getDisplayTokens(tokens);
$: singlePlainBlock =
displayTokens.length === 1 &&
@ -278,6 +282,7 @@
id={`${id}-${tokenIdx}`}
tokens={token.tokens}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -313,6 +318,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -348,6 +354,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -360,6 +367,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -375,6 +383,7 @@
id={`${id}-${tokenIdx}-detail-group`}
tokens={token.items}
messageDone={done}
{preview}
{allowEmbeds}
>
<div slot="content">
@ -389,7 +398,7 @@
grouped={true}
open={$settings?.expandDetails ?? false}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
/>
{:else if textContent.length > 0}
<Collapsible
@ -398,7 +407,7 @@
attributes={detailToken?.attributes}
messageDone={done}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
dir="auto"
>
<div class="mb-1.5" slot="content">
@ -407,6 +416,7 @@
tokens={marked.lexer(decode(detailToken.text))}
attributes={detailToken?.attributes}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -422,7 +432,7 @@
attributes={detailToken?.attributes}
messageDone={done}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
dir="auto"
/>
{/if}
@ -440,6 +450,7 @@
resultContent={getDetailTextContent(token)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
/>
{:else if textContent.length > 0}
<Collapsible
@ -448,6 +459,7 @@
attributes={token?.attributes}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
dir="auto"
>
<div class=" mb-1.5" slot="content">
@ -456,6 +468,7 @@
tokens={marked.lexer(decode(token.text))}
attributes={token?.attributes}
{done}
{preview}
{editCodeBlock}
{onTaskClick}
{sourceIds}
@ -471,6 +484,7 @@
attributes={token?.attributes}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
dir="auto"
/>
{/if}

View file

@ -39,8 +39,10 @@
export let mergeResponses;
export let addMessages;
export let forkHandler: Function | null = null;
export let triggerScroll;
export let readOnly = false;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
@ -71,6 +73,7 @@
{editMessage}
{deleteMessage}
{readOnly}
{preview}
{editCodeBlock}
{topPadding}
{onInsertToNote}
@ -97,7 +100,9 @@
{continueResponse}
{regenerateResponse}
{addMessages}
{forkHandler}
{readOnly}
{preview}
{editCodeBlock}
{topPadding}
/>
@ -122,7 +127,9 @@
{mergeResponses}
{triggerScroll}
{addMessages}
{forkHandler}
{readOnly}
{preview}
{editCodeBlock}
{topPadding}
{onInsertToNote}

View file

@ -28,6 +28,7 @@
export let isLastMessage;
export let readOnly = false;
export let preview = false;
export let editCodeBlock = true;
export let setInputText: Function = () => {};
@ -45,6 +46,7 @@
export let mergeResponses: Function;
export let addMessages: Function;
export let forkHandler: Function | null = null;
export let triggerScroll: Function;
@ -250,7 +252,11 @@
>
{#if $settings?.displayMultiModelResponsesInTabs ?? false}
<div class="w-full">
<div class=" flex w-full mb-4.5 border-b border-gray-200 dark:border-gray-850">
<div
class=" flex w-full mb-4.5 border-b border-gray-200 dark:border-gray-850 {preview
? 'hidden'
: ''}"
>
<div
class="flex gap-2 scrollbar-none overflow-x-auto w-fit text-center font-normal bg-transparent pt-1 text-sm"
on:wheel|preventDefault={(e) => {
@ -319,7 +325,9 @@
groupedMessageIds[selectedModelIdx].messageIds.length - 1;
}}
{addMessages}
{forkHandler}
{readOnly}
{preview}
{topPadding}
{onInsertToNote}
/>
@ -336,14 +344,17 @@
groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]}
<div
class=" snap-center w-full max-w-full m-1 border {history.messages[messageId]
?.modelIdx == modelIdx
? `bg-gray-50 dark:bg-gray-850 border-gray-100 dark:border-gray-800 border-2 ${
$mobile ? 'min-w-full' : 'min-w-80'
}`
: `border-gray-100/30 dark:border-gray-850/30 border-dashed ${
$mobile ? 'min-w-full' : 'min-w-80'
}`} transition-all p-5 rounded-2xl"
class="snap-center w-full max-w-full transition-all {preview
? ''
: `m-1 border p-5 rounded-2xl ${
history.messages[messageId]?.modelIdx == modelIdx
? `bg-gray-50 dark:bg-gray-850 border-gray-100 dark:border-gray-800 border-2 ${
$mobile ? 'min-w-full' : 'min-w-80'
}`
: `border-gray-100/30 dark:border-gray-850/30 border-dashed ${
$mobile ? 'min-w-full' : 'min-w-80'
}`
}`}"
on:click={async () => {
onGroupClick(_messageId, modelIdx);
}}
@ -376,7 +387,9 @@
groupedMessageIds[modelIdx].messageIds.length - 1;
}}
{addMessages}
{forkHandler}
{readOnly}
{preview}
{editCodeBlock}
{topPadding}
{onInsertToNote}
@ -389,7 +402,7 @@
{/if}
</div>
{#if !readOnly}
{#if !preview && !readOnly}
{#if !Object.keys(groupedMessageIds).find((modelIdx) => {
const { messageIds } = groupedMessageIds[modelIdx];
const _messageId = messageIds[groupedMessageIdsIdx[modelIdx]];

View file

@ -159,11 +159,13 @@
export let submitMessage: Function;
export let continueResponse: Function;
export let regenerateResponse: Function;
export let forkHandler: Function | null = null;
export let addMessages: Function;
export let isLastMessage = true;
export let readOnly = false;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
@ -664,13 +666,15 @@
</div>
<div class="flex-auto w-0 pl-1 relative">
<Name>
<Tooltip content={model?.name ?? message.model} placement="top-start">
<span id="response-message-model-name" class="line-clamp-1 text-black dark:text-white">
{model?.name ?? message.model}
</span>
</Tooltip>
</Name>
{#if !preview}
<Name>
<Tooltip content={model?.name ?? message.model} placement="top-start">
<span id="response-message-model-name" class="line-clamp-1 text-black dark:text-white">
{model?.name ?? message.model}
</span>
</Tooltip>
</Name>
{/if}
<div>
<div class="chat-{message.role} w-full min-w-full">
@ -821,7 +825,7 @@
!readOnly &&
($settings?.showFloatingActionButtons ?? true)}
save={!readOnly}
preview={!readOnly}
preview={preview || !readOnly}
{editCodeBlock}
{topPadding}
done={($settings?.chatFadeStreamingText ?? true)
@ -890,7 +894,22 @@
</div>
</div>
{#if !edit}
{#if preview && message.timestamp}
<div class="mt-0.5 flex justify-start whitespace-nowrap text-gray-600 dark:text-gray-500">
<Tooltip
className="flex self-center"
content={formatMessageTimestampFull(message.timestamp * 1000)}
placement="bottom"
>
<time
datetime={new Date(message.timestamp * 1000).toISOString()}
class="ml-1 shrink-0 whitespace-nowrap text-[0.6875rem] tabular-nums text-gray-400 dark:text-gray-600 select-none"
>
{formatMessageTimestamp(message.timestamp * 1000)}
</time>
</Tooltip>
</div>
{:else if !edit}
<div
bind:this={buttonsContainerElement}
class="flex items-center justify-start overflow-x-auto whitespace-nowrap buttons text-gray-600 dark:text-gray-500 mt-0.5 [&>*]:shrink-0"
@ -1054,6 +1073,37 @@
</button>
</Tooltip>
{#if message.done && !readOnly && forkHandler}
<Tooltip content="Fork chat" placement="bottom">
<button
aria-label="Fork chat"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
forkHandler?.(message.id);
}}
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
</svg>
</button>
</Tooltip>
{/if}
{#if onInsertToNote && visibleResponseContent}
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
<button

View file

@ -32,6 +32,10 @@
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
$: detailButtonClassName = `w-fit py-0.5 ${
preview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
$: displayItems = buildOutputDisplayItems(output) as OutputDisplayItem[];
</script>
@ -64,6 +68,7 @@
id={`${id}-${displayItem.id}`}
tokens={displayItem.tokens}
messageDone={done}
{preview}
>
<div slot="content">
{#each displayItem.tokens as detailToken, detailIndex}
@ -75,7 +80,7 @@
grouped={true}
open={$settings?.expandDetails ?? false}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
/>
{:else if detailToken.text?.length > 0}
<Collapsible
@ -84,7 +89,7 @@
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
>
<div class="mb-1.5" slot="content">
<div class="markdown-prose">
@ -92,6 +97,7 @@
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
content={detailToken.text}
{done}
{preview}
{editCodeBlock}
/>
</div>
@ -105,7 +111,7 @@
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full"
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
buttonClassName={detailButtonClassName}
/>
{/if}
{/each}
@ -120,6 +126,7 @@
resultContent={detailToken.text}
open={$settings?.expandDetails ?? false}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
/>
{:else if detailToken.text?.length > 0}
<Collapsible
@ -128,6 +135,7 @@
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
>
<div class="mb-1.5" slot="content">
<div class="markdown-prose">
@ -135,6 +143,7 @@
id={`${id}-${displayItem.id}-detail`}
content={detailToken.text}
{done}
{preview}
{editCodeBlock}
/>
</div>
@ -148,6 +157,7 @@
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
/>
{/if}
{/if}

View file

@ -39,6 +39,7 @@
export let isFirstMessage: boolean;
export let readOnly: boolean;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
@ -415,7 +416,8 @@
>
<time
datetime={new Date(message.timestamp * 1000).toISOString()}
class="invisible group-hover:visible {($settings?.chatBubble ?? true)
class="{preview ? '' : 'invisible group-hover:visible'} {($settings?.chatBubble ??
true)
? 'mr-1'
: 'ml-1 shrink-0 whitespace-nowrap'} text-[0.6875rem] tabular-nums text-gray-400 dark:text-gray-600 select-none"
>
@ -424,7 +426,7 @@
</Tooltip>
{/if}
{#if !($settings?.chatBubble ?? true)}
{#if !preview && !($settings?.chatBubble ?? true)}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
<button
@ -518,7 +520,7 @@
</div>
{/if}
{/if}
{#if !readOnly}
{#if !preview && !readOnly}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)
@ -546,7 +548,7 @@
</Tooltip>
{/if}
{#if message?.content}
{#if !preview && message?.content}
<Tooltip content={$i18n.t('Copy')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)
@ -575,7 +577,7 @@
{/if}
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.delete_message ?? false)}
{#if !readOnly && (!isFirstMessage || siblings.length > 1)}
{#if !preview && !readOnly && (!isFirstMessage || siblings.length > 1)}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)
@ -608,7 +610,7 @@
{/if}
{/if}
{#if $settings?.chatBubble ?? true}
{#if !preview && ($settings?.chatBubble ?? true)}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
<button

View file

@ -1,27 +1,35 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
const i18n: Writable<any> = getContext('i18n');
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { getTimeRange } from '$lib/utils';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
dayjs.extend(localizedFormat);
export let chats = [];
export let chats: any[] = [];
export let chatListLoading = false;
export let allChatsLoaded = false;
export let loadHandler: Function = null;
export let showOwnerInfo = false;
export let page = 1;
export let total = 0;
export let perPage = 10;
export let orderBy: 'title' | 'updated_at' = 'updated_at';
export let direction: 'asc' | 'desc' = 'desc';
export let onPageChange: Function = () => {};
export let onSort: Function = () => {};
let chatList = null;
let chatList: any[] | null = null;
let totalPages = 1;
let pages: (number | 'ellipsis')[] = [];
const init = async () => {
if (chats.length === 0) {
@ -31,34 +39,43 @@
...chat,
time_range: getTimeRange(chat.updated_at)
}));
chatList.sort((a, b) => {
if (direction === 'asc') {
return a[orderBy] > b[orderBy] ? 1 : -1;
} else {
return a[orderBy] < b[orderBy] ? 1 : -1;
}
});
}
};
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = 'asc';
}
init();
const setSortKey = (key: 'title' | 'updated_at') => {
onSort(key);
};
let orderBy = 'updated_at';
let direction = 'desc'; // 'asc' or 'desc'
const buildPages = (currentPage: number, pageCount: number): (number | 'ellipsis')[] => {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1);
}
const items: (number | 'ellipsis')[] = [1];
if (currentPage > 3) {
items.push('ellipsis');
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(pageCount - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
items.push(i);
}
if (currentPage < pageCount - 2) {
items.push('ellipsis');
}
items.push(pageCount);
return items;
};
$: if (chats) {
init();
}
$: totalPages = Math.max(1, Math.ceil(total / perPage));
$: pages = buildPages(page, totalPages);
</script>
{#if chatList}
@ -153,7 +170,6 @@
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
draggable="false"
href={`/c/${chat.id}`}
on:click={() => (show = false)}
>
<div class="text-ellipsis line-clamp-1 w-full sm:basis-3/5">
{chat?.title}
@ -177,19 +193,48 @@
</a>
{/each}
{#if !allChatsLoaded && loadHandler}
<Loader
on:visible={(e) => {
if (!chatListLoading) {
loadHandler();
}
}}
>
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
</div>
</Loader>
{#if totalPages > 1}
<div class="flex items-center justify-center gap-1.5 pt-2">
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
disabled={chatListLoading || page <= 1}
aria-label={$i18n.t('Previous page')}
on:click={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-3.5" strokeWidth="2" />
</button>
{#each pages as item, index (item === 'ellipsis' ? `ellipsis-${index}` : item)}
{#if item === 'ellipsis'}
<span
class="inline-flex size-7 items-center justify-center text-xs text-gray-400 select-none dark:text-gray-600"
>
...
</span>
{:else}
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium transition hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300 {item ===
page
? 'bg-gray-50 text-gray-800 dark:bg-gray-850 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-500'}"
aria-label={`Page ${item}`}
aria-current={item === page ? 'page' : undefined}
on:click={() => onPageChange(item)}
>
{item}
</button>
{/if}
{/each}
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
disabled={chatListLoading || page >= totalPages}
aria-label={$i18n.t('Next page')}
on:click={() => onPageChange(page + 1)}
>
<ChevronRight className="size-3.5" strokeWidth="2" />
</button>
</div>
{/if}
</div>
{/if}

View file

@ -11,18 +11,21 @@
import ChatList from './ChatList.svelte';
import FolderKnowledge from './FolderKnowledge.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getChatListByFolderId } from '$lib/apis/chats';
import { getSharedFolderChats } from '$lib/apis/folders';
export let folder: any = null;
let selectedTab = 'chats';
const CHATS_PAGE_SIZE = 10;
let page = 1;
let totalChats = 0;
let orderBy: 'title' | 'updated_at' = 'updated_at';
let direction: 'asc' | 'desc' = 'desc';
let currentFolderId: string | null = null;
let chats: any[] | null = null;
let chatListLoading = false;
let allChatsLoaded = false;
$: showOwnerInfo = Boolean(
folder?.shared ||
@ -30,42 +33,68 @@
(folder?.access_grants?.length ?? 0) > 0
);
const loadChats = async () => {
// getSharedFolderChats returns all users' chats in one shot; no pagination
allChatsLoaded = true;
const setSortKey = (key: 'title' | 'updated_at') => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'title' ? 'asc' : 'desc';
}
page = 1;
setChatList();
};
const setPage = (nextPage: number) => {
if (nextPage === page || chatListLoading) {
return;
}
page = nextPage;
setChatList();
};
const setChatList = async () => {
const folderId = folder?.id;
chats = null;
page = 1;
allChatsLoaded = false;
chatListLoading = false;
if (folder && folder.id) {
if (folderId) {
// Always use the shared folder endpoint so owners also see
// chats created by users who have write access to this folder.
const res = await getSharedFolderChats(localStorage.token, folder.id).catch((error) => {
chatListLoading = true;
const res = await getSharedFolderChats(localStorage.token, folderId, {
page,
sortBy: orderBy,
sortDir: direction
}).catch((error) => {
console.error(error);
return null;
});
chatListLoading = false;
if (res && res.chats) {
chats = res.chats;
allChatsLoaded = true;
totalChats = res.total ?? res.chats.length;
} else {
// Fallback to regular API (e.g. if user has no shared access)
const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch(
() => []
);
chats = fallback || [];
chats = [];
totalChats = 0;
}
} else {
chats = [];
totalChats = 0;
}
};
$: if (folder) {
$: if (folder?.id && folder.id !== currentFolderId) {
currentFolderId = folder.id;
page = 1;
setChatList();
}
$: if (!folder?.id && currentFolderId !== null) {
currentFolderId = null;
chats = [];
totalChats = 0;
}
</script>
<div>
@ -105,9 +134,14 @@
<ChatList
{chats}
{chatListLoading}
{allChatsLoaded}
loadHandler={loadChats}
{showOwnerInfo}
{page}
total={totalChats}
perPage={CHATS_PAGE_SIZE}
{orderBy}
{direction}
onPageChange={setPage}
onSort={setSortKey}
/>
{:else}
<div class="py-10">

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { getContext, onDestroy, tick } 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';
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}
<LinkPreview.Portal>
<LinkPreview.Content
class="z-[9999] hidden max-h-[min(17.5rem,calc(100vh-1.5rem))] w-[20rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 transition md:block dark:bg-gray-850 dark:text-white dark:ring-white/10"
{side}
{align}
{sideOffset}
>
<div class="border-b border-gray-50/60 px-3 py-2 dark:border-gray-800/25">
<div class="truncate text-[13px] font-medium 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-[11px] [&_.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"
chatId={`chat-hover-preview-${chatId}`}
user={$user}
prompt=""
readOnly={true}
preview={true}
{selectedModels}
atSelectedModel={null}
{history}
autoScroll={true}
{messagesContainerId}
messagesCount={8}
sendMessage={() => {}}
continueResponse={() => {}}
regenerateResponse={() => {}}
mergeResponses={() => {}}
chatActionHandler={() => {}}
/>
{/if}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>
{/if}

View file

@ -9,6 +9,7 @@
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
import { LinkPreview } from 'bits-ui';
import {
archiveChatById,
cloneChatById,
@ -37,6 +38,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChatHoverPreview from './ChatHoverPreview.svelte';
import ChatIcon from './icons/Chat.svelte';
import MoreHorizontalIcon from './icons/MoreHorizontal.svelte';
import SparklesIcon from './icons/Sparkles.svelte';
@ -90,6 +92,7 @@
let chat = null;
let mouseOver = false;
let openPreview = false;
// Local state: tracks the last updatedAt seen while the user was viewing
// this chat. Survives prop refreshes from sidebar data re-fetches that
@ -247,6 +250,7 @@
const onDragStart = (event) => {
event.stopPropagation();
openPreview = false;
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
@ -329,6 +333,7 @@
const renameHandler = async () => {
chatTitle = title;
confirmEdit = true;
openPreview = false;
await tick();
@ -444,11 +449,18 @@
</DragGhost>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={!confirmEdit && !readonly}
on:mouseenter={() => {
mouseOver = true;
}}
on:mouseleave={() => {
mouseOver = false;
}}
>
{#if confirmEdit}
<div
@ -490,90 +502,99 @@
/>
</div>
{:else}
<a
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: selected
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
href="/c/{id}"
on:click={() => {
dispatch('select');
if ($selectedFolder) {
selectedFolder.set(null);
}
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
on:dblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
doubleClicked = true;
renameHandler();
}}
on:mouseenter={(e) => {
mouseOver = true;
}}
on:mouseleave={(e) => {
mouseOver = false;
}}
on:focus={(e) => {}}
draggable="false"
<LinkPreview.Root
openDelay={300}
closeDelay={0}
disabled={$mobile || confirmEdit || dragged}
bind:open={openPreview}
>
{#if ownerUserId}
<Tooltip content={ownerName || 'Unknown'}>
<img
src="/api/v1/users/{ownerUserId}/profile/image"
alt=""
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
/>
</Tooltip>
{/if}
<LinkPreview.Trigger
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: selected
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
href="/c/{id}"
onclick={() => {
openPreview = false;
dispatch('select');
<!-- Loading spinner for active chat (left side) -->
{#if active}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
if ($selectedFolder) {
selectedFolder.set(null);
}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full" />
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
ondblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
doubleClicked = true;
renameHandler();
}}
draggable="false"
>
{#if ownerUserId}
<Tooltip content={ownerName || 'Unknown'}>
<img
src="/api/v1/users/{ownerUserId}/profile/image"
alt=""
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
/>
</Tooltip>
{/if}
<!-- Loading spinner for active chat (left side) -->
{#if active}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-normal text-gray-800 dark:text-gray-200'
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
>
{title}
</div>
</div>
<!-- Time ago indicator -->
{#if (updatedAt ?? createdAt) && !showInlineActions}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo(updatedAt ?? createdAt)}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full"></div>
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-normal text-gray-800 dark:text-gray-200'
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
>
{title}
</div>
</div>
{/if}
</a>
<!-- Time ago indicator -->
{#if (updatedAt ?? createdAt) && !showInlineActions}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo((updatedAt ?? createdAt) as number)}
</div>
{/if}
</LinkPreview.Trigger>
<ChatHoverPreview
chatId={id}
title={chatTitle || title}
{openPreview}
side="right"
align="center"
/>
</LinkPreview.Root>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->

View file

@ -44,7 +44,6 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FolderModal from './Folders/FolderModal.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folderRegistry = {};
export let open = false;
@ -385,24 +384,46 @@
}, 500);
};
const SIDEBAR_CHATS_PAGE_SIZE = 10;
/** @type {any[] | null} */
let chats = null;
export const setFolderItems = async () => {
let chatsPage = 1;
let hasMoreChats = false;
let chatsLoading = false;
export const setFolderItems = async (append = false) => {
await tick();
if (open) {
if (open && !chatsLoading) {
// Always use getSharedFolderChats so owners also see chats
// created by users who have write access to this folder.
const nextPage = append ? chatsPage + 1 : 1;
chatsLoading = true;
try {
const res = await getSharedFolderChats(localStorage.token, folderId);
chats = res?.chats ?? [];
const res = await getSharedFolderChats(localStorage.token, folderId, {
page: nextPage
});
const nextChats = res?.chats ?? [];
chats = append ? [...(chats ?? []), ...nextChats] : nextChats;
chatsPage = nextPage;
hasMoreChats = res?.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
} catch (error) {
// Fallback to regular API
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return [];
});
const fallback = await getChatListByFolderId(localStorage.token, folderId, nextPage).catch(
(error) => {
toast.error(`${error}`);
return [];
}
);
chats = append ? [...(chats ?? []), ...(fallback ?? [])] : (fallback ?? []);
chatsPage = nextPage;
hasMoreChats = (fallback?.length ?? 0) === SIDEBAR_CHATS_PAGE_SIZE;
} finally {
chatsLoading = false;
}
} else {
chats = null;
chatsPage = 1;
hasMoreChats = false;
}
};
@ -564,7 +585,7 @@
await selectedFolder.set({ ...folders[folderId], ...folder });
}
await goto('/');
await goto(`/folders/${folderId}`);
if ($mobile) {
showSidebar.set(!$showSidebar);
@ -678,7 +699,7 @@
</div>
<div slot="content" class="w-full">
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0}
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0 || hasMoreChats}
<div
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
>
@ -731,12 +752,41 @@
}}
/>
{/each}
{#if hasMoreChats}
<button
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
disabled={chatsLoading}
on:click={() => setFolderItems(true)}
>
{#if chatsLoading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{$i18n.t('Show more')}
{/if}
</button>
{/if}
</div>
{/if}
{#if chats === null}
<div class="flex justify-center items-center p-2">
<Spinner className="size-4 text-gray-500" />
{#if chats === null && chatsLoading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{/if}
</div>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
const i18n: Writable<any> = getContext('i18n');
const dispatch = createEventDispatcher();
import { goto } from '$app/navigation';
@ -13,16 +15,18 @@
import ChevronRight from './icons/ChevronRight.svelte';
import Eye from './icons/Eye.svelte';
import FolderIcon from './icons/Folder.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folder: any;
export let allSharedFolders: any[] = [];
export let className = '';
const SIDEBAR_CHATS_PAGE_SIZE = 10;
let expanded = false;
let chats: any[] = [];
let loading = false;
let loaded = false;
let page = 1;
let hasMoreChats = false;
$: children = allSharedFolders.filter((f) => f.parent_id === folder.id);
$: permission = folder.permission || 'read';
@ -35,16 +39,27 @@
}
};
const loadChats = async () => {
const loadChats = async (append = false) => {
if (loading) {
return;
}
loading = true;
try {
const res = await getSharedFolderChats(localStorage.token, folder.id);
const nextPage = append ? page + 1 : 1;
const res = await getSharedFolderChats(localStorage.token, folder.id, {
page: nextPage
});
if (res) {
chats = res.chats || [];
const nextChats = res.chats || [];
chats = append ? [...chats, ...nextChats] : nextChats;
page = nextPage;
hasMoreChats = res.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
}
} catch (e) {
console.error('Failed to load shared folder chats', e);
chats = [];
hasMoreChats = false;
} finally {
loading = false;
loaded = true;
@ -92,10 +107,15 @@
{#if expanded}
<div class="pl-3">
{#if loading}
<div class="flex items-center gap-1.5 py-1 px-2 text-xs text-gray-400">
<Spinner className="size-3" />
<span>{$i18n.t('Loading...')}</span>
{#if loading && !loaded}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{#each chats as chat (chat.id)}
@ -115,7 +135,29 @@
<svelte:self folder={child} {allSharedFolders} />
{/each}
{#if chats.length === 0 && children.length === 0}
{#if hasMoreChats}
<button
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
disabled={loading}
on:click={() => loadChats(true)}
>
{#if loading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{$i18n.t('Show more')}
{/if}
</button>
{/if}
{#if chats.length === 0 && children.length === 0 && !hasMoreChats}
<div class="text-[11px] text-gray-400 dark:text-gray-600 py-1 px-2">
{$i18n.t('Empty')}
</div>

View file

@ -148,6 +148,27 @@
voices = res?.voices ?? [];
};
const toModelKnowledgeReference = (item: any) => {
if (!item || typeof item !== 'object') {
return item;
}
return Object.fromEntries(
[
'id',
'name',
'type',
'description',
'context',
'legacy',
'collection_name',
'collection_names'
]
.filter((key) => item[key] !== undefined && item[key] !== null && item[key] !== '')
.map((key) => [key, item[key]])
);
};
const submitHandler = async () => {
loading = true;
@ -194,7 +215,7 @@
}
if (knowledge.length > 0) {
info.meta.knowledge = knowledge;
info.meta.knowledge = knowledge.map(toModelKnowledgeReference);
} else {
if (info.meta.knowledge) {
delete info.meta.knowledge;

View file

@ -38,7 +38,7 @@
"{{COUNT}}y_time_ago": "",
"{{label}} contains invalid JSON": "",
"{{label}} must be a JSON object": "",
"{{model}} download has been canceled": "",
"{{model}} download has been canceled": "{{model}} download has been cancelled",
"{{modelName}} profile image": "",
"{{NAMES}} reacted with {{REACTION}}": "",
"{{user}}'s Chats": "",
@ -198,8 +198,8 @@
"Allow users to create API keys for programmatic access.": "",
"Allow users to edit existing images.": "",
"Allow users to generate images from prompts.": "",
"Allow users to organize chats into folders.": "",
"Allow users to save memories for more personalized responses.": "",
"Allow users to organize chats into folders.": "Allow users to organise chats into folders.",
"Allow users to save memories for more personalized responses.": "Allow users to save memories for more personalised responses.",
"Allow users to search the web from chats.": "",
"Allow users to share chats with the Open WebUI community.": "",
"Allow users to use channels for shared conversations.": "",
@ -377,7 +377,7 @@
"Button Prompt": "",
"by {{name}}": "",
"By {{name}}": "",
"By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.": "",
"By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.": "By supporting the project through sponsorship or an enterprise licence, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.",
"Bypass Embedding and Retrieval": "",
"Bypass Web Loader": "",
"Cache Base Model List": "",
@ -444,7 +444,7 @@
"Choose how uploaded documents are parsed before indexing.": "",
"Choose how web result pages are fetched and read.": "",
"Choose the browser voice used for speech output.": "",
"Choose the color theme used by the interface.": "",
"Choose the color theme used by the interface.": "Choose the colour theme used by the interface.",
"Choose the configured text-to-speech service voice.": "",
"Choose the engine used to read assistant responses aloud.": "",
"Choose the engine used to transcribe voice input.": "",
@ -676,7 +676,7 @@
"Current Password": "",
"Current streak": "",
"Custom": "",
"Custom color": "",
"Custom color": "Custom colour",
"Custom description enabled": "",
"Custom Gender": "",
"Custom message shown while an account waits for approval.": "",
@ -835,7 +835,7 @@
"Download all users as CSV.": "",
"Download as JSON": "",
"Download as SVG": "",
"Download canceled": "",
"Download canceled": "Download cancelled",
"Download every user's chat history as JSON.": "",
"Download the application database when supported.": "",
"Download the current admin configuration as JSON.": "",
@ -914,7 +914,7 @@
"Enable Mirostat sampling for controlling perplexity.": "",
"Enable sub-agents": "",
"Enable Web Search": "",
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "",
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "Enable, disable, or customise the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.",
"Enabled": "",
"End Tag": "",
"Endpoint": "",
@ -971,7 +971,7 @@
"Enter GitHub Raw URL": "",
"Enter Google PSE API Key": "",
"Enter Google PSE Engine Id": "",
"Enter hex color (e.g. #FF0000)": "",
"Enter hex color (e.g. #FF0000)": "Enter hex colour (e.g. #FF0000)",
"Enter Image Size (e.g. 512x512)": "",
"Enter Jina API Base URL": "",
"Enter Jina API Key": "",
@ -1034,7 +1034,7 @@
"Enter Tika Server URL": "",
"Enter to Send": "",
"Enter token threshold": "",
"Enter Tokenizer Model": "",
"Enter Tokenizer Model": "Enter Tokeniser Model",
"Enter Top K": "",
"Enter Top K Reranker": "",
"Enter URL (e.g. http://127.0.0.1:7860/)": "",
@ -1059,7 +1059,7 @@
"Enter Your Password": "",
"Enter Your Role": "",
"Enter Your Username": "",
"Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.": "",
"Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.": "Enterprise licences also include dedicated support, customisation options, and more, at a fraction of the cost of building and maintaining this stack internally.",
"Enterprise licensing": "",
"Entra ID": "",
"Environment Variables": "",
@ -1095,7 +1095,7 @@
"Example: mail": "",
"Example: ou=users,dc=foo,dc=example": "",
"Example: sAMAccountName or uid or userPrincipalName": "",
"Exceeded the number of seats in your license. Please contact support to increase the number of seats.": "",
"Exceeded the number of seats in your license. Please contact support to increase the number of seats.": "Exceeded the number of seats in your licence. Please contact support to increase the number of seats.",
"Exclude": "",
"Execute code": "",
"Execute code for analysis": "",
@ -1173,7 +1173,7 @@
"Failed to refresh terminals: {{error}}": "",
"Failed to remove member": "",
"Failed to render diagram": "",
"Failed to render visualization": "",
"Failed to render visualization": "Failed to render visualisation",
"Failed to save connections": "",
"Failed to save conversation": "",
"Failed to save models configuration": "",
@ -1523,7 +1523,7 @@
"Let speech interrupt the assistant during a voice call.": "",
"Let users rate assistant responses.": "",
"lexical": "",
"License": "",
"License": "Licence",
"Lifecycle JSON": "",
"Lifetime tokens": "",
"Lift List": "",
@ -1859,7 +1859,7 @@
"October": "",
"Off": "",
"Okay, Let's Go!": "",
"Older messages are summarized when estimated context exceeds this token limit.": "",
"Older messages are summarized when estimated context exceeds this token limit.": "Older messages are summarised when estimated context exceeds this token limit.",
"OLED Dark": "",
"Ollama": "",
"Ollama API": "",
@ -2502,8 +2502,8 @@
"Suggest completions while users type chat messages.": "",
"Suggested": "",
"Suggested prompts": "",
"Summarize older chat history when the conversation context grows large.": "",
"Summarize this note.": "",
"Summarize older chat history when the conversation context grows large.": "Summarise older chat history when the conversation context grows large.",
"Summarize this note.": "Summarise this note.",
"Support": "",
"Support this plugin:": "",
"Supported MIME Types": "",
@ -2652,8 +2652,8 @@
"Token Cap": "",
"Token counts are estimates and may not reflect actual API usage": "",
"Token Threshold": "",
"Tokenizer Model": "",
"Tokenizer model used for transformer token splitting.": "",
"Tokenizer Model": "Tokeniser Model",
"Tokenizer model used for transformer token splitting.": "Tokeniser model used for transformer token splitting.",
"tokens": "",
"Tokens": "",
"Too verbose": "",
@ -2760,7 +2760,7 @@
"Use Chat Completions API": "",
"Use device vibration feedback on supported Android devices.": "",
"Use discovered scopes": "",
"Use groups to organize your users and assign permissions.": "",
"Use groups to organize your users and assign permissions.": "Use groups to organise your users and assign permissions.",
"Use LLM": "",
"Use proxy environment variables to fetch page contents.": "",
"Use search results without fetching page contents.": "",
@ -2894,7 +2894,7 @@
"Yesterday": "",
"You": "",
"You are a sub-agent...": "",
"You are currently using a trial license. Please contact support to upgrade your license.": "",
"You are currently using a trial license. Please contact support to upgrade your license.": "You are currently using a trial licence. Please contact support to upgrade your licence.",
"You can only chat with a maximum of {{maxCount}} file(s) at a time.": "",
"You can personalize your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you.": "You can personalise your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you.",
"You cannot upload an empty file.": "",
@ -2908,7 +2908,7 @@
"You do not have permission to upload files to this knowledge base.": "",
"You do not have permission to upload files.": "",
"You do not have permission to upload web content.": "",
"You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.": "",
"You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.": "You have more than 50 users, which often means this workspace is supporting organisational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.",
"You have no archived conversations.": "",
"You have no shared conversations.": "",
"You have shared this chat": "",

File diff suppressed because it is too large Load diff

View file

@ -203,25 +203,28 @@ export const convertMessagesToHistory = (messages) => {
let messageId = null;
for (const message of messages) {
messageId = uuidv4();
if (parentMessageId !== null) {
history.messages[parentMessageId].childrenIds = [
...history.messages[parentMessageId].childrenIds,
messageId
];
}
messageId = message?.id ?? uuidv4();
const parentId = message?.parentId ?? parentMessageId;
history.messages[messageId] = {
...message,
id: messageId,
parentId: parentMessageId,
parentId,
childrenIds: []
};
parentMessageId = messageId;
}
for (const message of Object.values(history.messages)) {
if (message.parentId && history.messages[message.parentId]) {
history.messages[message.parentId].childrenIds = [
...history.messages[message.parentId].childrenIds,
message.id
];
}
}
history.currentId = messageId;
return history;
};

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import Chat from '$lib/components/chat/Chat.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getFolderById } from '$lib/apis/folders';
import { selectedFolder } from '$lib/stores';
let ready = false;
onMount(async () => {
const folderId = $page.params.folderId;
if (!folderId) {
await goto('/');
return;
}
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!folder) {
await goto('/');
return;
}
await selectedFolder.set(folder);
ready = true;
});
onDestroy(() => {
selectedFolder.set(null);
});
</script>
{#if ready}
<Chat />
{:else}
<div class="w-full h-screen flex items-center justify-center">
<Spinner />
</div>
{/if}

38
uv.lock generated
View file

@ -191,15 +191,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506 },
]
[[package]]
name = "appdirs"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 },
]
[[package]]
name = "apscheduler"
version = "3.11.2"
@ -1375,20 +1366,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
]
[[package]]
name = "fs"
version = "2.4.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "appdirs" },
{ name = "setuptools" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261 },
]
[[package]]
name = "fsspec"
version = "2025.3.0"
@ -1415,19 +1392,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821 },
]
[[package]]
name = "gcp-storage-emulator"
version = "2024.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fs" },
{ name = "google-crc32c" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/c2/a0b0e1e54fdd9453603d90939faf652e2488b617c8752edc4ebcd89f1686/gcp_storage_emulator-2024.8.3.tar.gz", hash = "sha256:e5d45e5c23a0344c1c4c44b8f8c36f7e8975ca1fcc5134cab608b96ddccd9225", size = 24928 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/bf/b6c717bd7a5b59244388057d36789e72c28bfaa5e51f3a494563a4e3028e/gcp_storage_emulator-2024.8.3-py3-none-any.whl", hash = "sha256:1dc4ea56a0caf50fc6092898b9461d08b494824bfbbcca168e2af5da89c053ce", size = 19385 },
]
[[package]]
name = "gitdb"
version = "4.0.12"
@ -3070,7 +3034,6 @@ all = [
{ name = "colbert-ai" },
{ name = "docker" },
{ name = "elasticsearch" },
{ name = "gcp-storage-emulator" },
{ name = "moto", extra = ["s3"] },
{ name = "oracledb" },
{ name = "pgvector" },
@ -3140,7 +3103,6 @@ requires-dist = [
{ name = "faster-whisper", specifier = "==1.2.1" },
{ name = "fpdf2", specifier = "==2.8.7" },
{ name = "ftfy", specifier = "==6.3.1" },
{ name = "gcp-storage-emulator", marker = "extra == 'all'", specifier = ">=2024.8.3" },
{ name = "google-api-python-client", specifier = "==2.197.0" },
{ name = "google-auth-httplib2", specifier = "==0.4.0" },
{ name = "google-auth-oauthlib", specifier = "==1.4.0" },