Merge branch 'dev' into dev

This commit is contained in:
エニラ 2026-03-22 23:16:52 -07:00 committed by GitHub
commit 84518986ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3259 additions and 267 deletions

View file

@ -473,6 +473,7 @@ WEBUI_ADMIN_NAME = os.environ.get('WEBUI_ADMIN_NAME', 'Admin')
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_EMAIL_HEADER', None)
WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_NAME_HEADER', None)
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_GROUPS_HEADER', None)
WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_ROLE_HEADER', None)
ENABLE_PASSWORD_VALIDATION = os.environ.get('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true'
@ -629,6 +630,13 @@ else:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
# WARNING: Experimental. Only enable if your upstream Responses API endpoint
# supports stateful sessions (i.e. server-side response storage with
# previous_response_id anchoring). Most proxies and third-party endpoints
# are stateless and will break if this is enabled.
ENABLE_RESPONSES_API_STATEFUL = os.environ.get('ENABLE_RESPONSES_API_STATEFUL', 'False').lower() == 'true'
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get('CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE', '')
if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == '':

View file

@ -403,6 +403,7 @@ from open_webui.config import (
EVALUATION_ARENA_MODELS,
# WebUI (OAuth)
ENABLE_OAUTH_ROLE_MANAGEMENT,
OAUTH_SUB_CLAIM,
OAUTH_ROLES_CLAIM,
OAUTH_EMAIL_CLAIM,
OAUTH_PICTURE_CLAIM,
@ -888,6 +889,7 @@ if any('access_control' in m.get('meta', {}) for m in arena_models):
migrate_access_control(model.get('meta', {}))
app.state.config.EVALUATION_ARENA_MODELS = arena_models
app.state.config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM

View file

@ -192,6 +192,16 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
return False
def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes wildcard public-write.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
return True
return False
def has_user_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes any non-wildcard user grant.

View file

@ -2,9 +2,9 @@ import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserModel
from open_webui.models.users import Users, UserModel, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@ -75,10 +75,6 @@ class FunctionWithValvesModel(BaseModel):
####################
class FunctionUserResponse(FunctionModel):
user: Optional[UserModel] = None
class FunctionResponse(BaseModel):
id: str
user_id: str
@ -90,6 +86,12 @@ class FunctionResponse(BaseModel):
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
class FunctionUserResponse(FunctionResponse):
user: Optional[UserResponse] = None
class FunctionForm(BaseModel):
id: str
@ -224,7 +226,12 @@ class FunctionsTable:
def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]:
with get_db_context(db) as db:
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
functions = (
db.query(Function)
.options(defer(Function.content))
.order_by(Function.updated_at.desc())
.all()
)
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
@ -233,8 +240,17 @@ class FunctionsTable:
return [
FunctionUserResponse.model_validate(
{
**FunctionModel.model_validate(func).model_dump(),
'user': (users_dict.get(func.user_id).model_dump() if func.user_id in users_dict else None),
**FunctionResponse.model_validate(func).model_dump(),
'user': (
UserResponse(
id=users_dict[func.user_id].id,
name=users_dict[func.user_id].name,
role=users_dict[func.user_id].role,
email=users_dict[func.user_id].email,
).model_dump()
if func.user_id in users_dict
else None
),
}
)
for func in functions

View file

@ -404,11 +404,34 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict:
async def query_collection(
request,
collection_names: list[str],
queries: list[str],
embedding_function,
k: int,
) -> dict:
# When request is provided, try hybrid search + reranking if enabled
if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
try:
reranking_function = (
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents))
if request.app.state.RERANKING_FUNCTION
else None
)
return await query_collection_with_hybrid_search(
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
)
except Exception as e:
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
results = []
error = False
@ -1126,31 +1149,13 @@ async def get_sources_from_items(
if full_context:
query_result = get_all_items_from_collections(collection_names)
else:
query_result = None # Initialize to None
if hybrid_search:
try:
query_result = await query_collection_with_hybrid_search(
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
)
except Exception as e:
log.debug('Error when using hybrid search, using non hybrid search as fallback.')
# fallback to non-hybrid search
if not hybrid_search and query_result is None:
query_result = await query_collection(
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
k=k,
)
query_result = await query_collection(
request,
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
k=k,
)
except Exception as e:
log.exception(e)

View file

@ -31,7 +31,7 @@ def search_serper(api_key: str, query: str, count: int, filter_list: Optional[li
SearchResult(
link=result['link'],
title=result.get('title'),
snippet=result.get('description'),
snippet=result.get('snippet'),
)
for result in results[:count]
]

View file

@ -35,6 +35,7 @@ from open_webui.env import (
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
WEBUI_AUTH_TRUSTED_ROLE_HEADER,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
@ -117,6 +118,7 @@ def create_session_response(request: Request, user, db, response: Response = Non
if set_cookie and response:
datetime_expires_at = datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None
max_age = int(expires_delta.total_seconds()) if expires_delta else None
response.set_cookie(
key='token',
value=token,
@ -124,6 +126,7 @@ def create_session_response(request: Request, user, db, response: Response = Non
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': max_age} if max_age is not None else {}),
)
user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
@ -181,6 +184,7 @@ async def get_session_user(
)
# Set the cookie token
max_age = int(expires_at - time.time()) if expires_at else None
response.set_cookie(
key='token',
value=token,
@ -188,6 +192,7 @@ async def get_session_user(
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': max_age} if max_age is not None else {}),
)
user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
@ -500,7 +505,7 @@ async def ldap_auth(
user = Auths.authenticate_user_by_email(email, db=db)
if user:
if user.role != 'admin' and ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
if ENABLE_LDAP_GROUP_CREATION:
Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try:
@ -561,12 +566,23 @@ async def signin(
)
user = Auths.authenticate_user_by_email(email, db=db)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != 'admin':
group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',')
group_names = [name.strip() for name in group_names if name.strip()]
if user:
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER:
group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',')
group_names = [name.strip() for name in group_names if name.strip()]
if group_names:
Groups.sync_groups_by_group_names(user.id, group_names, db=db)
if group_names:
Groups.sync_groups_by_group_names(user.id, group_names, db=db)
if WEBUI_AUTH_TRUSTED_ROLE_HEADER:
trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip()
if trusted_role in {'admin', 'user', 'pending'}:
if user.role != trusted_role:
Users.update_user_role_by_id(user.id, trusted_role, db=db)
elif trusted_role:
log.warning(
f'Ignoring invalid trusted role header value: {trusted_role}'
)
elif WEBUI_AUTH == False:
admin_email = 'admin@localhost'

View file

@ -36,7 +36,7 @@ from open_webui.models.channels import (
ChannelWebhookModel,
ChannelWebhookForm,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant
from open_webui.models.messages import (
Messages,
MessageModel,
@ -88,7 +88,7 @@ def channel_has_access(
):
return True
if not strict and permission == 'write' and has_public_read_access_grant(channel.access_grants):
if not strict and permission == 'write' and has_public_write_access_grant(channel.access_grants):
return True
return False

View file

@ -722,7 +722,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S
)
else:
# File path doesnt exist, return the content as .txt if possible
file_content = file.content.get('content', '')
file_content = file.data.get('content', '')
file_name = file.filename
# Create a generator that encodes the file content

View file

@ -30,6 +30,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import (
has_permission,
has_public_read_access_grant,
has_public_write_access_grant,
filter_allowed_access_grants,
)
from open_webui.models.access_grants import AccessGrants
@ -234,7 +235,7 @@ async def get_note_by_id(
permission='write',
db=db,
)
or has_public_read_access_grant(note.access_grants)
or has_public_write_access_grant(note.access_grants)
)
return NoteResponse(**note.model_dump(), write_access=write_access)

View file

@ -2,6 +2,7 @@ import asyncio
import hashlib
import json
import logging
import re
from typing import Optional
from urllib.parse import urlparse
@ -739,8 +740,16 @@ def get_azure_allowed_params(api_version: str) -> set[str]:
return allowed_params
def is_openai_reasoning_model(model: str) -> bool:
return model.lower().startswith(('o1', 'o3', 'o4', 'gpt-5'))
def is_openai_new_model(model: str) -> bool:
model_lower = model.lower()
# o-series models (o1, o3, o4, o5, ...)
if re.match(r'^o\d+', model_lower):
return True
# gpt-N where N >= 5 (gpt-5, gpt-5.2, gpt-6, ...)
m = re.match(r'^gpt-(\d+)', model_lower)
if m and int(m.group(1)) >= 5:
return True
return False
def convert_to_azure_payload(url, payload: dict, api_version: str):
@ -750,7 +759,7 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
allowed_params = get_azure_allowed_params(api_version)
# Special handling for o-series models
if is_openai_reasoning_model(model):
if is_openai_new_model(model):
# Convert max_tokens to max_completion_tokens for o-series models
if 'max_tokens' in payload:
payload['max_completion_tokens'] = payload['max_tokens']
@ -770,6 +779,31 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
return url, payload
# Fields accepted by the Responses API for each input item type.
RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = {
'message': {'type', 'role', 'content'},
'function_call': {'type', 'call_id', 'name', 'arguments', 'id'},
'function_call_output': {'type', 'call_id', 'output'},
}
def _normalize_stored_item(item: dict) -> dict:
"""Strip local-only fields from a stored output item before replaying it.
Open WebUI stores extra bookkeeping fields (``id``, ``status``,
``started_at``, ``ended_at``, ``duration``, ``_tag_type``,
``attributes``, ``summary``, etc.) that the Responses API does
not accept. This helper returns a copy containing only the
fields the API understands.
"""
item_type = item.get('type', '')
allowed = RESPONSES_ALLOWED_FIELDS.get(item_type)
if allowed is None:
# Unknown type — pass through as-is (e.g. reasoning, extension items).
return item
return {k: v for k, v in item.items() if k in allowed}
def convert_to_responses_payload(payload: dict) -> dict:
"""
Convert Chat Completions payload to Responses API format.
@ -789,7 +823,7 @@ def convert_to_responses_payload(payload: dict) -> dict:
# Check for stored output items (from previous Responses API turn)
stored_output = msg.get('output')
if stored_output and isinstance(stored_output, list):
input_items.extend(stored_output)
input_items.extend(_normalize_stored_item(item) for item in stored_output)
continue
if role == 'system':
@ -799,6 +833,38 @@ def convert_to_responses_payload(payload: dict) -> dict:
system_content = '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text')
continue
# Handle assistant messages with tool_calls (from convert_output_to_messages)
if role == 'assistant' and msg.get('tool_calls'):
# Add text content as message if present
if content:
text = content if isinstance(content, str) else '\n'.join(
p.get('text', '') for p in content if p.get('type') == 'text'
)
if text.strip():
input_items.append({
'type': 'message', 'role': 'assistant',
'content': [{'type': 'output_text', 'text': text}],
})
# Convert each tool_call to a function_call input item
for tool_call in msg['tool_calls']:
func = tool_call.get('function', {})
input_items.append({
'type': 'function_call',
'call_id': tool_call.get('id', ''),
'name': func.get('name', ''),
'arguments': func.get('arguments', '{}'),
})
continue
# Handle tool result messages
if role == 'tool':
input_items.append({
'type': 'function_call_output',
'call_id': msg.get('tool_call_id', ''),
'output': msg.get('content', ''),
})
continue
# Convert content format
text_type = 'output_text' if role == 'assistant' else 'input_text'
@ -820,12 +886,21 @@ def convert_to_responses_payload(payload: dict) -> dict:
responses_payload = {**payload, 'input': input_items}
# Forward previous_response_id when the middleware has set it
# (only used when ENABLE_RESPONSES_API_STATEFUL is enabled).
previous_response_id = responses_payload.pop('previous_response_id', None)
if previous_response_id:
responses_payload['previous_response_id'] = previous_response_id
if system_content:
responses_payload['instructions'] = system_content
if 'max_tokens' in responses_payload:
responses_payload['max_output_tokens'] = responses_payload.pop('max_tokens')
if 'max_completion_tokens' in responses_payload:
responses_payload['max_output_tokens'] = responses_payload.pop('max_completion_tokens')
# Remove Chat Completions-only parameters not supported by the Responses API
for unsupported_key in (
'stream_options',
@ -864,11 +939,36 @@ def convert_to_responses_payload(payload: dict) -> dict:
def convert_responses_result(response: dict) -> dict:
"""
Convert non-streaming Responses API result.
Just add done flag - pass through raw response, frontend handles output.
Convert non-streaming Responses API result to Chat Completions format.
Extracts text from message output items so all downstream consumers
(frontend tasks, get_content_from_response) work without modification.
"""
response['done'] = True
return response
output_items = response.get('output', [])
content = ''
for item in output_items:
if item.get('type') == 'message':
for part in item.get('content', []):
if part.get('type') == 'output_text':
content += part.get('text', '')
return {
'id': response.get('id', ''),
'object': 'chat.completion',
'model': response.get('model', ''),
'choices': [
{
'index': 0,
'message': {
'role': 'assistant',
'content': content,
},
'finish_reason': 'stop',
}
],
'usage': response.get('usage', {}),
}
@router.post('/chat/completions')
@ -980,7 +1080,7 @@ async def generate_chat_completion(
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Check if model is a reasoning model that needs special handling
if is_openai_reasoning_model(payload['model']):
if is_openai_new_model(payload['model']):
payload = openai_reasoning_model_handler(payload)
elif 'api.openai.com' not in url:
# Remove "max_completion_tokens" from the payload for backward compatibility

View file

@ -2464,6 +2464,7 @@ async def query_collection_handler(
)
else:
return await query_collection(
request,
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(

View file

@ -82,13 +82,22 @@ class RedisDict:
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
def set(self, mapping: dict):
pipe = self.redis.pipeline()
if not mapping:
self.redis.delete(self.name)
return
pipe.delete(self.name)
if mapping:
pipe.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()})
# Fetch existing keys before writing so we know which ones to remove.
# HKEYS is cheap — it transfers only short key strings, not large JSON values.
existing_keys = set(self.redis.hkeys(self.name))
new_keys = set(mapping.keys())
keys_to_remove = existing_keys - new_keys
pipe.execute()
# HSET first (add/update all new values), then HDEL (remove stale keys).
# We never DELETE the whole hash — this eliminates the race window
# where concurrent readers would see an empty models dict.
self.redis.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()})
if keys_to_remove:
self.redis.hdel(self.name, *keys_to_remove)
def get(self, key, default=None):
try:

View file

@ -1874,6 +1874,7 @@ async def query_knowledge_files(
# Query vector collections if any
if collection_names:
query_results = await query_collection(
__request__,
collection_names=collection_names,
queries=[query],
embedding_function=embedding_function,

View file

@ -5,6 +5,7 @@ from open_webui.models.users import UserModel
from open_webui.models.groups import Groups
from open_webui.models.access_grants import (
has_public_read_access_grant,
has_public_write_access_grant,
has_user_access_grant,
strip_user_access_grants,
)
@ -225,7 +226,7 @@ def filter_allowed_access_grants(
return access_grants
# Check if user can share publicly
if has_public_read_access_grant(access_grants) and not has_permission(
if (has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)) and not has_permission(
user_id,
public_permission_key,
default_permissions,

View file

@ -143,7 +143,7 @@ def get_license_data(app, key):
pn, pt = nt(pb)
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
if not data.get('exp') and data.get('exp') < datetime.now().date():
if not data.get('exp') or data.get('exp') < datetime.now().date():
return False
data_handler(data)

View file

@ -98,6 +98,7 @@ from open_webui.utils.misc import (
get_content_from_message,
convert_output_to_messages,
merge_assistant_content_into_output_messages,
strip_empty_content_blocks,
)
from open_webui.utils.tools import (
get_tools,
@ -136,6 +137,7 @@ from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
ENABLE_RESPONSES_API_STATEFUL,
)
from open_webui.utils.headers import include_user_info_headers
from open_webui.constants import TASKS
@ -850,7 +852,11 @@ def handle_responses_streaming_event(
if item.get('type') == 'reasoning' and item.get('status') != 'completed':
item['status'] = 'completed'
return new_output, {'usage': response_data.get('usage'), 'done': True}
return new_output, {
'usage': response_data.get('usage'),
'done': True,
'response_id': response_data.get('id'),
}
elif event_type == 'response.in_progress':
# State Machine Event: In Progress
@ -938,6 +944,13 @@ def process_tool_result(
tool_result_embeds = []
EXTERNAL_TOOL_TYPES = ('external', 'action', 'terminal')
# Support (HTMLResponse, result_context) tuples: the optional second
# element lets tool authors provide the LLM with actionable context
# about the generated embed instead of the generic fallback message.
result_context = None
if isinstance(tool_result, tuple) and len(tool_result) == 2 and isinstance(tool_result[0], HTMLResponse):
tool_result, result_context = tool_result
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get('Content-Disposition', '')
if 'inline' in content_disposition:
@ -945,11 +958,14 @@ def process_tool_result(
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
if result_context is not None and isinstance(result_context, (str, dict, list)):
tool_result = result_context
else:
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
'status': 'error',
@ -1000,20 +1016,37 @@ def process_tool_result(
)
if 'text/html' in content_type:
# Support (html_content, result_context) nested tuple
result_context = None
html_content = tool_result
if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2:
html_content, result_context = tool_result
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
tool_result_embeds.append(html_content)
if result_context is not None and isinstance(result_context, (str, dict, list)):
tool_result = result_context
else:
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
elif location:
# Support (html_content, result_context) nested tuple for location embeds
result_context = None
if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2:
_, result_context = tool_result
tool_result_embeds.append(location)
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
if result_context is not None and isinstance(result_context, (str, dict, list)):
tool_result = result_context
else:
tool_result = {
'status': 'success',
'code': 'ui_component',
'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.',
}
tool_result_files = []
@ -2531,7 +2564,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# so system terminals work even when no other tools are selected)
if terminal_id:
try:
terminal_tools = await get_terminal_tools(
terminal_tools, system_prompt = await get_terminal_tools(
request,
terminal_id,
user,
@ -2539,11 +2572,25 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
if terminal_tools:
tools_dict = {**tools_dict, **terminal_tools}
if system_prompt:
form_data['messages'] = add_or_update_system_message(
system_prompt,
form_data['messages'],
append=True,
)
except Exception as e:
log.exception(e)
if direct_tool_servers:
for tool_server in direct_tool_servers:
system_prompt = tool_server.pop('system_prompt', None)
if system_prompt:
form_data['messages'] = add_or_update_system_message(
system_prompt,
form_data['messages'],
append=True,
)
tool_specs = tool_server.pop('specs', [])
for tool in tool_specs:
@ -2641,6 +2688,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
)
# Strip empty text content blocks from multimodal messages
# to prevent errors from providers like Gemini and Claude
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
return form_data, metadata, events
@ -2891,7 +2942,7 @@ async def background_tasks_handler(ctx):
}
)
if title == None and len(messages) == 2:
if title == None and len(messages) == 2 and (not messages_map or len(messages_map) <= 2):
title = messages[0].get('content', user_message)
Chats.update_chat_title_by_id(metadata['chat_id'], title)
@ -3034,6 +3085,7 @@ async def non_streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'role': 'assistant',
'content': content,
'output': response_output,
@ -3366,6 +3418,11 @@ async def streaming_chat_response_handler(response, ctx):
output = []
usage = None
prior_output = []
last_response_id = None
def full_output():
return prior_output + output if prior_output else output
reasoning_tags_param = metadata.get('params', {}).get('reasoning_tags')
DETECT_REASONING_TAGS = reasoning_tags_param is not False
@ -3400,6 +3457,8 @@ async def streaming_chat_response_handler(response, ctx):
nonlocal content
nonlocal usage
nonlocal output
nonlocal prior_output
nonlocal last_response_id
response_tool_calls = []
@ -3471,19 +3530,29 @@ async def streaming_chat_response_handler(response, ctx):
)
# Check for Responses API events (type field starts with "response.")
elif data.get('type', '').startswith('response.'):
output, response_metadata = handle_responses_streaming_event(data, output)
processed_data = {
'output': output,
'content': serialize_output(output),
'output': full_output(),
'content': serialize_output(full_output()),
}
# print(data)
# print(processed_data)
# Merge any metadata (usage, done, etc.)
# Merge any metadata (usage, etc.)
# Strip 'done' — response.completed emits
# it but we may still need to execute tool
# calls. The outer middleware manages the
# actual completion signal.
if response_metadata:
if ENABLE_RESPONSES_API_STATEFUL:
response_id = response_metadata.pop('response_id', None)
if response_id:
last_response_id = response_id
processed_data.update(response_metadata)
processed_data.pop('done', None)
await event_emitter(
{
@ -3825,13 +3894,13 @@ async def streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{
'content': serialize_output(output),
'output': output,
'content': serialize_output(full_output()),
'output': full_output(),
},
)
else:
data = {
'content': serialize_output(output),
'content': serialize_output(full_output()),
}
if delta:
@ -3888,6 +3957,34 @@ async def streaming_chat_response_handler(response, ctx):
if response_tool_calls:
tool_calls.append(_split_tool_calls(response_tool_calls))
# Responses API path: extract function_call items from output
if not response_tool_calls and output:
# Collect call_ids that already have results,
# including those from prior_output so we don't
# re-process tool calls from a previous turn.
handled_call_ids = {
item.get('call_id')
for item in (prior_output + output)
if item.get('type') == 'function_call_output'
}
responses_api_tool_calls = []
for item in output:
if (
item.get('type') == 'function_call'
and item.get('call_id') not in handled_call_ids
):
arguments = item.get('arguments', '{}')
responses_api_tool_calls.append({
'id': item.get('call_id', ''),
'index': len(responses_api_tool_calls),
'function': {
'name': item.get('name', ''),
'arguments': arguments if isinstance(arguments, str) else json.dumps(arguments),
},
})
if responses_api_tool_calls:
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
if response.background:
await response.background()
@ -3919,26 +4016,32 @@ async def streaming_chat_response_handler(response, ctx):
response_tool_calls = tool_calls.pop(0)
# Append function_call items for each tool call
# (Responses API already has them from streaming, so skip duplicates)
existing_call_ids = {
item.get('call_id') for item in output
if item.get('type') == 'function_call'
}
for tc in response_tool_calls:
call_id = tc.get('id', '')
func = tc.get('function', {})
output.append(
{
'type': 'function_call',
'id': call_id or output_id('fc'),
'call_id': call_id,
'name': func.get('name', ''),
'arguments': func.get('arguments', '{}'),
'status': 'in_progress',
}
)
if call_id not in existing_call_ids:
func = tc.get('function', {})
output.append(
{
'type': 'function_call',
'id': call_id or output_id('fc'),
'call_id': call_id,
'name': func.get('name', ''),
'arguments': func.get('arguments', '{}'),
'status': 'in_progress',
}
)
await event_emitter(
{
'type': 'chat:completion',
'data': {
'content': serialize_output(output),
'output': output,
'content': serialize_output(full_output()),
'output': full_output(),
},
}
)
@ -4180,11 +4283,20 @@ async def streaming_chat_response_handler(response, ctx):
**form_data,
'model': model_id,
'stream': True,
'messages': [
}
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
system_message = get_system_message(form_data['messages'])
new_form_data['messages'] = (
([system_message] if system_message else [])
+ convert_output_to_messages(output, raw=True)
)
new_form_data['previous_response_id'] = last_response_id
else:
new_form_data['messages'] = [
*form_data['messages'],
*convert_output_to_messages(output, raw=True),
],
}
]
res = await generate_chat_completion(
request,
@ -4194,7 +4306,31 @@ async def streaming_chat_response_handler(response, ctx):
)
if isinstance(res, StreamingResponse):
# Save accumulated output and start fresh.
# Responses API output_index values are relative
# to the current response — a clean output list
# keeps indices aligned. The display prefix
# ensures the UI shows tool history during
# streaming.
prior_output = list(output)
# Trim the trailing empty placeholder message
# so it doesn't persist as a ghost item once
# the new stream produces real content.
if (
prior_output
and prior_output[-1].get('type') == 'message'
and prior_output[-1].get('status') == 'in_progress'
):
msg_parts = prior_output[-1].get('content', [])
if (
not msg_parts
or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip())
):
prior_output.pop()
output = []
await stream_body_handler(res, new_form_data)
output[:0] = prior_output
prior_output = []
else:
break
except Exception as e:
@ -4383,6 +4519,7 @@ async def streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
@ -4392,7 +4529,13 @@ async def streaming_chat_response_handler(response, ctx):
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'usage': usage},
{'done': True, 'usage': usage},
)
else:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
# Send a webhook notification if the user is not active
@ -4429,10 +4572,17 @@ async def streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
},
)
else:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
if response.background is not None:
await response.background()

View file

@ -446,6 +446,31 @@ def append_or_update_assistant_message(content: str, messages: list[dict]):
return messages
def strip_empty_content_blocks(messages: list[dict]) -> list[dict]:
"""
Remove empty text content blocks from multimodal message content arrays.
Providers like Gemini and Claude reject messages where a text block has
an empty string. This can happen when a user sends only file/image
attachments without typing any text.
"""
for message in messages:
content = message.get('content')
if isinstance(content, list):
cleaned = [
block
for block in content
if not (
isinstance(block, dict)
and block.get('type') == 'text'
and not block.get('text', '').strip()
)
]
if cleaned:
message['content'] = cleaned
return messages
def openai_chat_message_template(model: str):
return {
'id': f'{model}-{str(uuid.uuid4())}',

View file

@ -1508,7 +1508,7 @@ class OAuthManager:
data={'id': user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != 'admin':
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
self.update_user_groups(
user=user,
user_data=user_data,
@ -1533,6 +1533,10 @@ class OAuthManager:
response = RedirectResponse(url=redirect_url, headers=response.headers)
# Compute cookie expiry from JWT lifetime
expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN)
cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None
# Set the cookie token
# Redirect back to the frontend with the JWT token
response.set_cookie(
@ -1541,6 +1545,7 @@ class OAuthManager:
httponly=False, # Required for frontend access
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': cookie_max_age} if cookie_max_age is not None else {}),
)
# Legacy cookies for compatibility with older frontend versions
@ -1551,6 +1556,7 @@ class OAuthManager:
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': cookie_max_age} if cookie_max_age is not None else {}),
)
try:
@ -1588,6 +1594,7 @@ class OAuthManager:
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': cookie_max_age, 'expires': cookie_expires} if cookie_max_age is not None else {}),
)
log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}')

View file

@ -828,6 +828,43 @@ async def get_terminal_cwd(
return None
async def get_terminal_system_prompt(
base_url: str,
headers: dict,
cookies: Optional[dict] = None,
) -> Optional[str]:
"""Fetch the system prompt from a terminal server.
Checks ``/api/config`` for the ``system`` feature flag first;
only fetches ``/system`` if the flag is present. Returns *None*
silently when the server doesn't support the endpoint.
"""
base = base_url.rstrip('/')
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=3),
trust_env=True,
) as session:
# 1. Check feature flag
async with session.get(f'{base}/api/config') as resp:
if resp.status != 200:
return None
config = await resp.json()
if not config.get('features', {}).get('system'):
return None
# 2. Fetch system prompt
async with session.get(
f'{base}/system', headers=headers, cookies=cookies or {}
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('prompt')
except Exception as e:
log.debug(f'Failed to fetch terminal system prompt: {e}')
return None
async def set_terminal_servers(request: Request):
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
@ -867,6 +904,25 @@ async def set_terminal_servers(request: Request):
request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs)
# Fetch system prompts concurrently (runs at cache time, not per-request)
connections_by_id = {c.get('id'): c for c in connections if c.get('id')}
async def _fetch_system_prompt(server):
connection = connections_by_id.get(server.get('id'))
if not connection:
return
headers = {}
if connection.get('auth_type', 'bearer') == 'bearer':
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
prompt = await get_terminal_system_prompt(server['url'], headers)
if prompt:
server['system_prompt'] = prompt
await asyncio.gather(
*[_fetch_system_prompt(s) for s in request.app.state.TERMINAL_SERVERS],
return_exceptions=True,
)
if request.app.state.redis is not None:
await request.app.state.redis.set('terminal_servers', json.dumps(request.app.state.TERMINAL_SERVERS))
@ -894,7 +950,7 @@ async def get_terminal_tools(
terminal_id: str,
user: UserModel,
extra_params: dict,
) -> dict[str, dict]:
) -> tuple[dict[str, dict], Optional[str]]:
"""Resolve tools for a terminal server identified by terminal_id.
- Finds the connection in TERMINAL_SERVER_CONNECTIONS
@ -941,14 +997,14 @@ async def get_terminal_tools(
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
# auth_type == "none": no Authorization header
system_prompt = server_data.get('system_prompt')
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
tools_dict = {}
for spec in specs:
function_name = spec['name']
# Inject CWD into run_command description
tool_spec = clean_openai_tool_schema(spec)
if function_name == 'run_command' and terminal_cwd:
tool_spec['description'] = (
tool_spec.get('description', '') + f'\n\nThe current working directory is: {terminal_cwd}'
@ -977,7 +1033,7 @@ async def get_terminal_tools(
'type': 'terminal',
}
return tools_dict
return tools_dict, system_prompt
async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]:

View file

@ -216,6 +216,10 @@ dev = [
"ruff>=0.15.5",
]
[tool.black]
line-length = 120
skip-string-normalization = true
[tool.ruff]
line-length = 120

View file

@ -392,12 +392,38 @@ export const getToolServersData = async (servers: object[]) => {
specs: convertOpenApiToToolPayload(res)
};
return {
const result: Record<string, any> = {
url: server?.url,
openapi: openapi,
info: info,
specs: specs
};
// Fetch system prompt if the server supports it
try {
const baseUrl = (server?.url ?? '').replace(/\/$/, '');
const configRes = await fetch(`${baseUrl}/api/config`);
if (configRes.ok) {
const config = await configRes.json();
if (config?.features?.system) {
const headers: Record<string, string> = {};
if (toolServerToken) {
headers['Authorization'] = `Bearer ${toolServerToken}`;
}
const systemRes = await fetch(`${baseUrl}/system`, { headers });
if (systemRes.ok) {
const systemData = await systemRes.json();
if (systemData?.prompt) {
result.system_prompt = systemData.prompt;
}
}
}
}
} catch (e) {
// Server doesn't support /system — that's fine
}
return result;
} else if (error) {
return {
error,

View file

@ -6,7 +6,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { extractFrontmatter } from '$lib/utils';
import { extractFrontmatter, nameToId } from '$lib/utils';
export let show = false;
@ -42,7 +42,7 @@
toast.success(successMessage);
let func = res;
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
func.id = func.id || nameToId(func.name);
const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted

View file

@ -4,6 +4,7 @@
const i18n = getContext('i18n');
import { nameToId } from '$lib/utils';
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
@ -36,7 +37,7 @@
};
$: if (name && !edit && !clone) {
id = name.replace(/\s+/g, '_').toLowerCase();
id = nameToId(name);
}
let codeEditor;

View file

@ -66,7 +66,7 @@
let showDeleteConfirmDialog = false;
const loadMessageData = async () => {
if (message && message?.data) {
if (message && message?.data === true) {
const res = await getMessageData(localStorage.token, channel?.id, message.id);
if (res) {
message.data = res;
@ -75,7 +75,7 @@
};
onMount(async () => {
if (message && message?.data) {
if (message && message?.data === true) {
await loadMessageData();
}
});
@ -285,12 +285,15 @@
e.currentTarget.src = '/favicon.png';
}}
/>
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.role === 'webhook'
? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`
: `${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>

View file

@ -45,7 +45,8 @@
showEmbeds,
selectedTerminalId,
showFileNavPath,
showFileNavDir
showFileNavDir,
chatRequestQueues
} from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -170,8 +171,7 @@
let files = [];
let params = {};
// Message queue for storing messages while generating
let messageQueue: { id: string; prompt: string; files: any[] }[] = [];
$: if (chatIdProp) {
navigateHandler();
@ -180,16 +180,10 @@
const navigateHandler = async () => {
loading = true;
// Save current queue to sessionStorage before navigating away
if (messageQueue.length > 0 && $chatId) {
sessionStorage.setItem(`chat-queue-${$chatId}`, JSON.stringify(messageQueue));
}
prompt = '';
messageInput?.setText('');
files = [];
messageQueue = [];
selectedToolIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
@ -206,28 +200,11 @@
await tick();
// Restore queue from sessionStorage
const storedQueueData = sessionStorage.getItem(`chat-queue-${chatIdProp}`);
if (storedQueueData) {
try {
const restoredQueue = JSON.parse(storedQueueData);
if (restoredQueue.length > 0) {
sessionStorage.removeItem(`chat-queue-${chatIdProp}`);
// Check if there are pending tasks (still generating)
const hasPendingTask = taskIds !== null && taskIds.length > 0;
if (!hasPendingTask) {
// No pending tasks - process the queue
files = restoredQueue.flatMap((m) => m.files);
await tick();
const combinedPrompt = restoredQueue.map((m) => m.prompt).join('\n\n');
await submitPrompt(combinedPrompt);
} else {
// Has pending tasks - show as queued (chatCompletedHandler will process)
messageQueue = restoredQueue;
}
}
} catch (e) {}
// Process any queued requests if the chat is idle
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
const isIdle = !lastMessage || lastMessage.role !== 'assistant' || lastMessage.done;
if (isIdle) {
await processNextInQueue(chatIdProp);
}
if (storageChatInput) {
@ -438,11 +415,15 @@
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:tasks:cancel') {
taskIds = null;
const responseMessage = history.messages[history.currentId];
// Set all response messages to done
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
if (event.message_id === history.currentId) {
taskIds = null;
// Set all response messages to done
for (const messageId of history.messages[message.parentId].childrenIds) {
history.messages[messageId].done = true;
}
await processNextInQueue($chatId);
} else {
message.done = true;
}
} else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content;
@ -559,6 +540,9 @@
history.messages[event.message_id] = message;
}
} else {
// Non-active chat completion: queue stays in the global store.
// navigateHandler will process it when the user returns to that chat.
}
};
@ -566,11 +550,20 @@
origin: string;
data: { type: string; text: string };
}) => {
if (event.origin !== window.origin) {
const isSameOrigin = event.origin === window.origin;
const type = event.data?.type;
// Prompt-related message types only submit text to the chat input —
// functionally equivalent to the user typing. When same-origin is
// enabled they go through immediately. When it is disabled (opaque
// origin) we show a confirmation dialog so the user stays in control.
const iframePromptTypes = ['input:prompt', 'input:prompt:submit', 'action:submit'];
if (!isSameOrigin && !iframePromptTypes.includes(type)) {
return;
}
if (event.data.type === 'action:submit') {
if (type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
@ -579,8 +572,7 @@
}
}
// Replace with your iframe's origin
if (event.data.type === 'input:prompt') {
if (type === 'input:prompt') {
console.debug(event.data.text);
const inputElement = document.getElementById('chat-input');
@ -591,12 +583,26 @@
}
}
if (event.data.type === 'input:prompt:submit') {
if (type === 'input:prompt:submit') {
console.debug(event.data.text);
if (event.data.text !== '') {
await tick();
submitPrompt(event.data.text);
if (isSameOrigin) {
await tick();
submitPrompt(event.data.text);
} else {
// Cross-origin: ask user to confirm before submitting
eventConfirmationInput = false;
eventConfirmationTitle = $i18n.t('Confirm Prompt from Embed');
eventConfirmationMessage = event.data.text;
eventCallback = async (confirmed: boolean) => {
if (confirmed) {
await tick();
submitPrompt(event.data.text);
}
};
showEventConfirmation = true;
}
}
}
};
@ -637,11 +643,14 @@
const audioQueueInstance = new AudioQueue(document.getElementById('audioElement'));
audioQueue.set(audioQueueInstance);
// Reset direct terminal enabled states — selectedTerminalId starts null on every page load
if ($settings?.terminalServers?.some((s) => s.enabled)) {
// Restore direct terminal enabled states based on persisted selectedTerminalId
if ($settings?.terminalServers?.length) {
settings.set({
...$settings,
terminalServers: ($settings.terminalServers ?? []).map((s) => ({ ...s, enabled: false }))
terminalServers: ($settings.terminalServers ?? []).map((s) => ({
...s,
enabled: $selectedTerminalId !== null && s.url === $selectedTerminalId
}))
});
}
@ -936,11 +945,11 @@
const onHistoryChange = (history) => {
if (history) {
cancelAnimationFrame(contentsRAF);
contentsRAF = requestAnimationFrame(() => {
clearTimeout(contentsRAF);
contentsRAF = setTimeout(() => {
getContents();
contentsRAF = null;
});
}, 0);
} else {
artifactContents.set([]);
}
@ -955,13 +964,12 @@
if (message?.role !== 'user' && message?.content) {
const {
codeBlocks: codeBlocks,
html: htmlContent,
css: cssContent,
js: jsContent
htmlGroups: htmlGroups
} = getCodeBlockContents(message.content);
if (htmlContent || cssContent || jsContent) {
const renderedContent = `
if (htmlGroups && htmlGroups.length > 0) {
htmlGroups.forEach((group) => {
const renderedContent = `
<!DOCTYPE html>
<html lang="en">
<head>
@ -972,19 +980,20 @@
background-color: white; /* Ensure the iframe has a white background */
}
${cssContent}
${group.css}
</${''}style>
</head>
<body>
${htmlContent}
${group.html}
<${''}script>
${jsContent}
${group.js}
</${''}script>
</body>
</html>
`;
contents = [...contents, { type: 'iframe', content: renderedContent }];
contents = [...contents, { type: 'iframe', content: renderedContent }];
});
} else {
// Check for SVG content
for (const block of codeBlocks) {
@ -1130,7 +1139,6 @@
chatFiles = [];
params = {};
taskIds = null;
messageQueue = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
@ -1239,7 +1247,7 @@
if (history.currentId) {
for (const message of Object.values(history.messages)) {
if (message && message.role === 'assistant') {
if (message && message.role === 'assistant' && message.done !== false) {
message.done = true;
}
}
@ -1282,6 +1290,24 @@
});
}
};
const processNextInQueue = async (targetChatId: string) => {
const queue = $chatRequestQueues[targetChatId];
if (!queue || queue.length === 0) return;
const combinedPrompt = queue.map((m) => m.prompt).join('\n\n');
const combinedFiles = queue.flatMap((m) => m.files);
chatRequestQueues.update((q) => {
const { [targetChatId]: _, ...rest } = q;
return rest;
});
files = combinedFiles;
await tick();
await submitPrompt(combinedPrompt);
};
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
const res = await chatCompleted(localStorage.token, {
model: modelId,
@ -1691,16 +1717,8 @@
createMessagesList(history, message.id)
);
// Process message queue immediately after main response finishes
if (messageQueue.length > 0) {
const combinedPrompt = messageQueue.map((m) => m.prompt).join('\n\n');
const combinedFiles = messageQueue.flatMap((m) => m.files);
messageQueue = [];
files = combinedFiles;
await tick();
await submitPrompt(combinedPrompt);
}
// Process next queued request if any
await processNextInQueue(chatId);
}
console.log(data);
@ -1764,16 +1782,15 @@
if (isGenerating) {
if ($settings?.enableMessageQueue ?? true) {
// Queue the message
// Enqueue the request
const _files = structuredClone(files);
messageQueue = [
...messageQueue,
{
id: uuidv4(),
prompt: userPrompt,
files: _files
}
];
chatRequestQueues.update((q) => ({
...q,
[$chatId]: [
...(q[$chatId] ?? []),
{ id: uuidv4(), prompt: userPrompt, files: _files }
]
}));
// Clear input
messageInput?.setText('');
prompt = '';
@ -2375,6 +2392,8 @@
generationController?.abort();
generationController = null;
}
await processNextInQueue($chatId);
};
const submitMessage = async (parentId, prompt) => {
@ -2638,12 +2657,8 @@
currentChatPage.set(1);
initNewChat();
await goto('/');
getChatList(localStorage.token, $currentChatPage).then((chats) => {
chats.set(chats);
});
getPinnedChatList(localStorage.token).then((pinnedChats) => {
pinnedChats.set(pinnedChats);
});
chats.set(await getChatList(localStorage.token, $currentChatPage));
pinnedChats.set(await getPinnedChatList(localStorage.token));
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
@ -2811,7 +2826,7 @@
</div>
</div>
<div class=" pb-2 z-10">
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
<MessageInput
bind:this={messageInput}
{history}
@ -2833,12 +2848,16 @@
{stopResponse}
{createMessagePair}
{onUpload}
{messageQueue}
messageQueue={$chatRequestQueues[$chatId] ?? []}
onQueueSendNow={async (id) => {
const item = messageQueue.find((m) => m.id === id);
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
if (item) {
// Remove from queue
messageQueue = messageQueue.filter((m) => m.id !== id);
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
// Stop current generation first
await stopResponse();
await tick();
@ -2849,17 +2868,25 @@
}
}}
onQueueEdit={(id) => {
const item = messageQueue.find((m) => m.id === id);
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
if (item) {
// Remove from queue
messageQueue = messageQueue.filter((m) => m.id !== id);
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
// Set files and restore prompt to input
files = item.files;
messageInput?.setText(item.prompt);
}
}}
onQueueDelete={(id) => {
messageQueue = messageQueue.filter((m) => m.id !== id);
const queue = $chatRequestQueues[$chatId] ?? [];
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
}}
onChange={(data) => {
if (!$temporaryChatEnabled) {

View file

@ -55,6 +55,7 @@
import { uploadFile } from '$lib/apis/files';
import { generateAutoCompletion } from '$lib/apis';
import { deleteFileById } from '$lib/apis/files';
import { getChatById } from '$lib/apis/chats';
import { getSessionUser } from '$lib/apis/auths';
import { getTools } from '$lib/apis/tools';
@ -806,8 +807,11 @@
const onDragOver = (e: DragEvent) => {
e.preventDefault();
// Check if a file is being dragged.
if (e.dataTransfer?.types?.includes('Files')) {
// Check if a file or a sidebar chat item is being dragged.
if (
e.dataTransfer?.types?.includes('Files') ||
e.dataTransfer?.types?.includes('text/plain')
) {
dragged = true;
} else {
dragged = false;
@ -825,6 +829,35 @@
e.preventDefault();
console.log(e);
// Check if the dropped data is a sidebar chat item
const textData = e.dataTransfer?.getData('text/plain');
if (textData) {
try {
const data = JSON.parse(textData);
if (data.type === 'chat' && data.id) {
// Fetch the chat to get its title, then add as a reference chat
const chat = await getChatById(localStorage.token, data.id);
if (chat) {
const chatItem = {
type: 'chat',
id: chat.id,
name: chat.title,
collection_name: '',
status: 'processed'
};
if (!files.find((f) => f.id === chatItem.id)) {
files = [...files, chatItem];
}
}
dragged = false;
e.stopPropagation();
return;
}
} catch (_) {
// Not valid JSON — fall through to file handling
}
}
if (e.dataTransfer?.files) {
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
@ -1022,8 +1055,8 @@
dropzoneElement = document.getElementById('chat-pane');
if (dropzoneElement) {
dropzoneElement.addEventListener('dragover', onDragOver);
dropzoneElement.addEventListener('drop', onDrop);
dropzoneElement.addEventListener('dragover', onDragOver, true);
dropzoneElement.addEventListener('drop', onDrop, true);
dropzoneElement.addEventListener('dragleave', onDragLeave);
}
@ -1041,8 +1074,8 @@
window.removeEventListener('blur', onBlur);
if (dropzoneElement) {
dropzoneElement.removeEventListener('dragover', onDragOver);
dropzoneElement.removeEventListener('drop', onDrop);
dropzoneElement.removeEventListener('dragover', onDragOver, true);
dropzoneElement.removeEventListener('drop', onDrop, true);
dropzoneElement.removeEventListener('dragleave', onDragLeave);
}
};
@ -1674,7 +1707,7 @@
</Tooltip>
{/if}
{#each selectedFilterIds as filterId}
{#each selectedFilterIds as filterId (filterId)}
{@const filter = toggleFilters.find((f) => f.id === filterId)}
{#if filter}
<Tooltip content={filter?.name} placement="top">

View file

@ -17,7 +17,7 @@
export let onSave = (e) => {};
let loading = false;
let loading = true;
let variableValues = {};
const submitHandler = async () => {
@ -33,14 +33,17 @@
const init = async () => {
loading = true;
variableValues = {};
for (const variable of Object.keys(variables)) {
if (variables[variable]?.default !== undefined) {
variableValues[variable] = variables[variable].default;
const newValues = {};
const keys = Object.keys(variables ?? {});
for (const key of keys) {
const variable = variables[key];
if (variable?.default !== undefined) {
newValues[key] = variable.default;
} else {
variableValues[variable] = '';
newValues[key] = '';
}
}
variableValues = newValues;
loading = false;
await tick();

View file

@ -8,6 +8,7 @@
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { disableSingleTilde } from '$lib/utils/marked/strikethrough-extension';
import { mentionExtension } from '$lib/utils/marked/mention-extension';
import colonFenceExtension from '$lib/utils/marked/colon-fence-extension';
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
import footnoteExtension from '$lib/utils/marked/footnote-extension';
@ -48,6 +49,7 @@
marked.use(markedExtension(options));
marked.use(citationExtension(options));
marked.use(footnoteExtension(options));
marked.use(colonFenceExtension(options));
marked.use(disableSingleTilde);
marked.use({
extensions: [

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { copyToClipboard } from '$lib/utils';
import { settings } from '$lib/stores';
import MarkdownTokens from './MarkdownTokens.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
export let id: string = '';
export let token: any;
export let tokenIdx: number = 0;
export let done: boolean = true;
export let editCodeBlock: boolean = true;
export let sourceIds: string[] = [];
export let onTaskClick: Function = () => {};
export let onSourceClick: Function = () => {};
const fenceType: string = token.fenceType ?? 'default';
const label = fenceType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let copied = false;
const copyText = async () => {
copied = true;
await copyToClipboard(token.text, null, $settings?.copyFormatted ?? false);
setTimeout(() => {
copied = false;
}, 1000);
};
</script>
<div class="relative group my-2 rounded-2xl border border-gray-100 dark:border-gray-800 px-4 py-3">
<!-- Header row: type badge + copy button -->
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{label}
</span>
<div class="invisible group-hover:visible flex gap-0.5">
<Tooltip content={copied ? $i18n.t('Copied') : $i18n.t('Copy')}>
<button
class="p-1 rounded-lg bg-transparent hover:bg-black/5 dark:hover:bg-white/5 transition"
on:click={(e) => {
e.stopPropagation();
copyText();
}}
>
{#if copied}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-green-500">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{:else}
<DocumentDuplicate className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
</div>
</div>
<!-- Content -->
<div class="prose-sm" dir="auto">
<MarkdownTokens
id={`${id}-${tokenIdx}-cf`}
tokens={token.tokens}
{done}
{editCodeBlock}
{sourceIds}
{onTaskClick}
{onSourceClick}
/>
</div>
</div>

View file

@ -23,6 +23,7 @@
import HtmlToken from './HTMLToken.svelte';
import Clipboard from '$lib/components/icons/Clipboard.svelte';
import ColonFenceBlock from './ColonFenceBlock.svelte';
export let id: string;
export let tokens: Token[];
@ -241,7 +242,7 @@
<li class="text-start">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1"
class=" translate-y-[1px] -translate-x-1 flex-shrink-0"
type="checkbox"
checked={item.checked}
on:change={(e) => {
@ -276,7 +277,7 @@
<li class="text-start {item?.task ? 'flex -translate-x-6.5 gap-3 ' : ''}">
{#if item?.task}
<input
class=""
class="flex-shrink-0"
type="checkbox"
checked={item.checked}
on:change={(e) => {
@ -434,6 +435,17 @@
{#if token.text}
<KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} />
{/if}
{:else if token.type === 'colonFence'}
<ColonFenceBlock
id={`${id}-${tokenIdx}`}
{token}
{tokenIdx}
{done}
{editCodeBlock}
{sourceIds}
{onTaskClick}
{onSourceClick}
/>
{:else if token.type === 'space'}
<div class="my-2" />
{:else}

View file

@ -168,6 +168,11 @@
let model = null;
$: model = $models.find((m) => m.id === message.model);
$: statusEntries = message?.statusHistory ?? [...(message?.status ? [message?.status] : [])];
$: hasVisibleStatus = (model?.info?.meta?.capabilities?.status_updates ?? true)
&& statusEntries.length > 0
&& !(statusEntries.at(-1)?.hidden ?? false);
let edit = false;
let editedContent = '';
let editTextAreaElement: HTMLTextAreaElement;
@ -197,7 +202,7 @@
const stopAudio = () => {
try {
speechSynthesis.cancel();
$audioQueue.stop();
$audioQueue?.stop();
} catch {}
if (speaking) {
@ -779,7 +784,7 @@
class="w-full flex flex-col relative {edit ? 'hidden' : ''}"
id="response-content-container"
>
{#if message.content === '' && !message.error && ((model?.info?.meta?.capabilities?.status_updates ?? true) ? (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0 || (message?.statusHistory?.at(-1)?.hidden ?? false) : true)}
{#if message.content === '' && !message.done && !message.error && !hasVisibleStatus}
<Skeleton />
{:else if message.content && message.error !== true}
<!-- always show message contents even if there's an error -->
@ -1010,7 +1015,7 @@
</button>
</Tooltip>
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true)}
{#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))}
<Tooltip content={$i18n.t('Read Aloud')} placement="bottom">
<button
aria-label={$i18n.t('Read Aloud')}

View file

@ -135,7 +135,7 @@
{#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
src={user?.id ? `${WEBUI_API_BASE_URL}/users/${user.id}/profile/image` : `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 user-message-profile-image'}
/>
</div>
@ -147,8 +147,8 @@
{#if message.user}
{$i18n.t('You')}
<span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
{:else if $settings.showUsername || $_user.name !== user.name}
{user.name}
{:else if $settings.showUsername || $_user?.name !== user?.name}
{user?.name ?? $i18n.t('You')}
{:else}
{$i18n.t('You')}
{/if}

View file

@ -49,6 +49,7 @@
let selectedMemory = null;
let showClearConfirmDialog = false;
let showDeleteConfirm = false;
$: filteredMemories = query
? memories.filter((m) => m.content?.toLowerCase().includes(query.toLowerCase()))
@ -225,7 +226,8 @@
<Tooltip content={$i18n.t('Edit')}>
<button
class="self-center w-fit text-sm p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={() => {
on:click={(e) => {
e.stopPropagation();
selectedMemory = memory;
showEditMemoryModal = true;
}}
@ -237,19 +239,10 @@
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
const res = await deleteMemoryById(
localStorage.token,
memory.id
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Memory deleted successfully'));
memories = await getMemories(localStorage.token);
}
on:click={(e) => {
e.stopPropagation();
selectedMemory = memory;
showDeleteConfirm = true;
}}
>
<GarbageBin className="size-4" strokeWidth="1.5" />
@ -302,6 +295,33 @@
}}
/>
<ConfirmDialog
title={$i18n.t('Delete Memory?')}
show={showDeleteConfirm}
on:confirm={async () => {
const res = await deleteMemoryById(localStorage.token, selectedMemory.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Memory deleted successfully'));
memories = await getMemories(localStorage.token);
}
showDeleteConfirm = false;
}}
on:cancel={() => {
showDeleteConfirm = false;
}}
>
<div class=" text-sm text-gray-500 flex-1">
{$i18n.t('Are you sure you want to delete this memory? This action cannot be undone.')}
<div class=" mt-2 bg-gray-50 dark:bg-gray-900 p-3 rounded-xl border border-gray-100 dark:border-gray-800 text-black dark:text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{selectedMemory?.content}
</div>
</div>
</ConfirmDialog>
<AddMemoryModal
bind:show={showAddMemoryModal}
on:save={async () => {

View file

@ -29,7 +29,7 @@
<div
bind:this={popupElement}
class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none"
class="fixed top-0 left-0 w-screen h-[100dvh] z-[99999] touch-none pointer-events-none"
>
<div class=" absolute text-white z-99999" style="top: {y + 10}px; left: {x + 10}px;">
<slot></slot>

View file

@ -801,8 +801,8 @@
if (!editor || !editor.view || editor.isDestroyed) {
return false;
}
// default logic
return from !== to;
// Only show when editor is focused and text is selected
return view.hasFocus() && from !== to;
}
}),
FloatingMenu.configure({
@ -905,6 +905,24 @@
},
editorProps: {
attributes: { id },
handleDrop: (view, event) => {
// Intercept sidebar chat item drops to prevent ProseMirror
// from inserting the raw JSON as text. The actual handling
// (adding as Reference Chat) is done by MessageInput's onDrop.
const textData = event.dataTransfer?.getData('text/plain');
if (textData) {
try {
const data = JSON.parse(textData);
if (data.type === 'chat' && data.id) {
// Swallow the drop — let the parent handler deal with it
return true;
}
} catch (_) {
// Not JSON, let ProseMirror handle normally
}
}
return false;
},
handlePaste: (view, event) => {
// Force plain-text pasting when richText === false
if (!richText) {
@ -1170,6 +1188,18 @@
}
},
onSelectionUpdate: onSelectionUpdate,
onBlur: () => {
// Force-hide floating menus when editor loses focus.
// shouldShow alone isn't enough because it only runs on transactions.
if (bubbleMenuElement) {
bubbleMenuElement.style.visibility = 'hidden';
bubbleMenuElement.style.opacity = '0';
}
if (floatingMenuElement) {
floatingMenuElement.style.visibility = 'hidden';
floatingMenuElement.style.opacity = '0';
}
},
enableInputRules: richText,
enablePasteRules: richText
});

View file

@ -21,6 +21,7 @@
export let show = false;
export let onUpdate = () => {};
export let onDelete: (id: string) => void = () => {};
let loading = false;
let chatList: any[] | null = null;
@ -158,6 +159,9 @@
onUpdate={() => {
init();
}}
onDelete={(id) => {
onDelete(id);
}}
loadHandler={loadMoreChats}
{unarchiveHandler}
>

View file

@ -50,6 +50,7 @@
let showDeleteConfirmDialog = false;
export let onUpdate = () => {};
export let onDelete: (id: string) => void = () => {};
export let loadHandler: null | Function = null;
export let unarchiveHandler: null | Function = null;
@ -69,6 +70,9 @@
toast.error(`${error}`);
});
if (res) {
onDelete(chatId);
}
onUpdate();
};
</script>

View file

@ -427,7 +427,7 @@
</div>
<div
id="chat-preview"
class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden"
class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden @container"
>
{#if messages === null}
<div

View file

@ -592,6 +592,12 @@
onUpdate={async () => {
await initChatList();
}}
onDelete={(id) => {
if ($chatId === id) {
chatId.set('');
window.history.replaceState({}, '', '/');
}
}}
/>
<ChannelModal

View file

@ -155,6 +155,12 @@
const archiveChatHandler = async (id) => {
try {
await archiveChatById(localStorage.token, id);
if ($chatId === id) {
await goto('/');
chatId.set('');
}
dispatch('change');
toast.success($i18n.t('Chat archived.'));
} catch (error) {

View file

@ -384,6 +384,7 @@
draggable="false"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={() => {
show = false;
cloneChatHandler();
}}
>

View file

@ -240,8 +240,13 @@
href="/playground"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async () => {
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
e.preventDefault();
show = false;
goto('/playground');
if ($mobile) {
await tick();
showSidebar.set(false);
@ -257,8 +262,13 @@
href="/admin"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async () => {
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
e.preventDefault();
show = false;
goto('/admin');
if ($mobile) {
await tick();
showSidebar.set(false);

View file

@ -64,6 +64,7 @@
loading = true;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
page = 1;
getPromptList();
}, 300);
}

View file

@ -8,6 +8,7 @@
import { user } from '$lib/stores';
import { updateToolAccessGrants } from '$lib/apis/tools';
import { nameToId } from '$lib/utils';
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
@ -45,7 +46,7 @@
};
$: if (name && !edit && !clone) {
id = name.replace(/\s+/g, '_').toLowerCase();
id = nameToId(name);
}
let codeEditor;

View file

@ -12,6 +12,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import AddAccessModal from './AddAccessModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
type AccessGrant = {
id?: string;
@ -159,6 +160,12 @@
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'read'
);
const hasPublicWriteGrant = (grants: AccessGrant[]): boolean =>
grants.some(
(grant) =>
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'write'
);
const currentGrants = (): AccessGrant[] =>
Array.isArray(accessGrants) ? (accessGrants as AccessGrant[]) : [];
@ -194,12 +201,12 @@
};
const setPublic = (isPublic: boolean) => {
// Remove all user:* grants
const filtered = currentGrants().filter(
(grant) =>
!(
grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'read'
grant.principal_id === '*'
)
);
if (isPublic) {
@ -212,6 +219,23 @@
commitAccessGrants(filtered);
};
const togglePublicWrite = () => {
let next = [...currentGrants()];
if (hasPublicWriteGrant(next)) {
next = next.filter(
(grant) =>
!(
grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'write'
)
);
} else {
next = upsertPrincipalGrant('user', '*', 'write', next);
}
commitAccessGrants(next);
};
const upsertPrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
@ -491,6 +515,20 @@
</div>
</div>
</div>
{#if hasPublicReadGrant(accessGrants ?? []) && accessRoles.includes('write')}
<div class="flex w-full justify-between mt-2 ml-0.5">
<div class="self-center text-xs">
{$i18n.t('Allow public write access')}
</div>
<Switch
state={hasPublicWriteGrant(accessGrants ?? [])}
on:change={() => {
togglePublicWrite();
}}
/>
</div>
{/if}
</div>
{#if share}

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,10 @@
"code": "ar-BH",
"title": "Arabic (Bahrain)"
},
{
"code": "az-AZ",
"title": "Azərbaycanca"
},
{
"code": "eu-ES",
"title": "Basque (Euskara)"

View file

@ -80,6 +80,9 @@ export const banners: Writable<Banner[]> = writable([]);
export const settings: Writable<Settings> = writable({});
export const audioQueue = writable<AudioQueue | null>(null);
export const chatRequestQueues: Writable<
Record<string, { id: string; prompt: string; files: any[] }[]>
> = writable({});
export const sidebarWidth = writable(260);

View file

@ -836,8 +836,12 @@ export const isYoutubeUrl = (url: string) => {
};
export const removeEmojis = (str: string) => {
// Regular expression to match emojis
const emojiRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g;
// Use Unicode property escape with the 'v' flag (ES2024) to match all
// standardised emoji sequences, including text-presentation emoji + variation
// selector (e.g. ❤️, ☀️, ✅), keycap sequences (e.g. 1⃣), ZWJ families
// (e.g. 👨‍👩‍👧‍👦) and flag sequences (e.g. 🏳️‍🌈).
// The previous surrogate-pair regex missed the entire BMP emoji category.
const emojiRegex = /\p{RGI_Emoji}/gv;
// Replace emojis with an empty string
return str.replace(emojiRegex, '');
@ -885,13 +889,19 @@ export const removeDetails = (content, types) => {
);
}
return segment;
});
}).trim();
};
export const removeAllDetails = (content) => {
// First pass: strip <details> blocks on the full string before code-fence
// splitting, so blocks whose body contains triple backticks are caught.
// (replaceOutsideCode splits on ``` fences, which breaks the <details>
// regex when the opening and closing tags land in different segments.)
content = content.replace(/<details[^>]*>[\s\S]*?<\/details>/gi, '');
// Second pass: catch any remaining blocks that live outside code fences
return replaceOutsideCode(content, (segment) => {
return segment.replace(/<details[^>]*>.*?<\/details>/gis, '');
});
}).trim();
};
export const processDetails = (content) => {
@ -1401,6 +1411,22 @@ export const slugify = (str: string): string => {
);
};
/**
* Convert a display name into a safe, underscore-delimited identifier.
* Strips emojis, accents, and any non-alphanumeric characters so the
* result is always accepted by backend validation.
*
* e.g. "My Tool 😄" "my_tool"
*/
export const nameToId = (name: string): string => {
return name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\w]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
};
export const extractInputVariables = (text: string): Record<string, any> => {
const regex = /{{\s*([^|}\s]+)\s*\|\s*([^}]+)\s*}}/g;
const regularRegex = /{{\s*([^|}\s]+)\s*}}/g;
@ -1703,9 +1729,18 @@ export const getCodeBlockContents = (content: string): object => {
let codeBlocks = [];
let htmlContent = '';
let cssContent = '';
let jsContent = '';
// Groups of related HTML/CSS/JS blocks. Each HTML block starts a new group;
// CSS and JS blocks attach to the current (most recent) group.
// This preserves the existing behaviour for "dumb" models that output
// separate html/css/js blocks meant to form a single page, while also
// allowing multiple distinct HTML blocks to produce separate artifacts.
let htmlGroups: Array<{ html: string; css: string; js: string }> = [];
const initDefaultGroup = () => {
if (htmlGroups.length === 0) {
htmlGroups.push({ html: '', css: '', js: '' });
}
};
if (codeBlockContents) {
codeBlockContents.forEach((block) => {
@ -1718,11 +1753,14 @@ export const getCodeBlockContents = (content: string): object => {
const { lang, code } = block;
if (lang === 'html') {
htmlContent += code + '\n';
// Each HTML block starts a new group
htmlGroups.push({ html: code + '\n', css: '', js: '' });
} else if (lang === 'css') {
cssContent += code + '\n';
initDefaultGroup();
htmlGroups[htmlGroups.length - 1].css += code + '\n';
} else if (lang === 'javascript' || lang === 'js') {
jsContent += code + '\n';
initDefaultGroup();
htmlGroups[htmlGroups.length - 1].js += code + '\n';
}
});
} else {
@ -1737,28 +1775,42 @@ export const getCodeBlockContents = (content: string): object => {
if (inlineHtml) {
inlineHtml.forEach((block) => {
const content = block.replace(/<\/?html>/gi, ''); // Remove <html> tags
htmlContent += content + '\n';
htmlGroups.push({ html: content + '\n', css: '', js: '' });
});
}
if (inlineCss) {
inlineCss.forEach((block) => {
const content = block.replace(/<\/?style>/gi, ''); // Remove <style> tags
cssContent += content + '\n';
initDefaultGroup();
htmlGroups[htmlGroups.length - 1].css += content + '\n';
});
}
if (inlineJs) {
inlineJs.forEach((block) => {
const content = block.replace(/<\/?script>/gi, ''); // Remove <script> tags
jsContent += content + '\n';
initDefaultGroup();
htmlGroups[htmlGroups.length - 1].js += content + '\n';
});
}
}
// Backward-compatible flat fields (merged from all groups)
const htmlContent = htmlGroups.map((g) => g.html).join('');
const cssContent = htmlGroups.map((g) => g.css).join('');
const jsContent = htmlGroups.map((g) => g.js).join('');
return {
codeBlocks: codeBlocks,
html: htmlContent.trim(),
css: cssContent.trim(),
js: jsContent.trim()
js: jsContent.trim(),
htmlGroups: htmlGroups
.filter((g) => g.html.trim() || g.css.trim() || g.js.trim())
.map((g) => ({
html: g.html.trim(),
css: g.css.trim(),
js: g.js.trim()
}))
};
};
export const parseFrontmatter = (content) => {

View file

@ -0,0 +1,56 @@
/**
* Marked extension for colon-fence blocks (:::type ... :::)
*
* Used by newer OpenAI chat models to wrap semantically distinct content:
* :::writing reusable prose (letters, articles, docs)
* :::code_execution code execution output
* :::search_results web search results
*
* The extension is generic and will tokenize any :::<identifier> block.
*/
function colonFenceTokenizer(this: any, src: string) {
// Match :::type at the start of a line, optionally followed by content, then closing :::
const match = /^:::([\w-]+)\n([\s\S]*?)(?:\n:::(?:\s*$|\n))/m.exec(src);
if (match) {
const fenceType = match[1];
const text = match[2].trim();
const raw = match[0];
const tokens: any[] = [];
this.lexer.blockTokens(text, tokens);
return {
type: 'colonFence',
raw,
fenceType,
text,
tokens
};
}
}
function colonFenceStart(src: string) {
const idx = src.match(/^:::\w/m);
return idx ? idx.index! : -1;
}
function colonFenceRenderer(token: any) {
return `<div class="colon-fence colon-fence-${token.fenceType}">${token.text}</div>`;
}
function colonFenceExtension() {
return {
name: 'colonFence',
level: 'block' as const,
start: colonFenceStart,
tokenizer: colonFenceTokenizer,
renderer: colonFenceRenderer
};
}
export default function (options = {}) {
return {
extensions: [colonFenceExtension()]
};
}

View file

@ -34,6 +34,7 @@
temporaryChatEnabled,
toolServers,
terminalServers,
selectedTerminalId,
showSearch,
showSidebar,
showControls,
@ -350,6 +351,16 @@
localStorage.showControls = value ? 'true' : 'false';
});
// Persist selectedTerminalId across page loads
selectedTerminalId.set(localStorage.selectedTerminalId ?? null);
selectedTerminalId.subscribe((value) => {
if (value === null) {
delete localStorage.selectedTerminalId;
} else {
localStorage.selectedTerminalId = value;
}
});
await tick();
loaded = true;