mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-11 22:52:54 +00:00
Merge branch 'dev' into dev
This commit is contained in:
commit
84518986ae
52 changed files with 3259 additions and 267 deletions
|
|
@ -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 == '':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S
|
|||
)
|
||||
else:
|
||||
# File path doesn’t 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())}',
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -216,6 +216,10 @@ dev = [
|
|||
"ruff>=0.15.5",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
skip-string-normalization = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -592,6 +592,12 @@
|
|||
onUpdate={async () => {
|
||||
await initChatList();
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
if ($chatId === id) {
|
||||
chatId.set('');
|
||||
window.history.replaceState({}, '', '/');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChannelModal
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
loading = true;
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getPromptList();
|
||||
}, 300);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
2145
src/lib/i18n/locales/az-AZ/translation.json
Normal file
2145
src/lib/i18n/locales/az-AZ/translation.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,10 @@
|
|||
"code": "ar-BH",
|
||||
"title": "Arabic (Bahrain)"
|
||||
},
|
||||
{
|
||||
"code": "az-AZ",
|
||||
"title": "Azərbaycanca"
|
||||
},
|
||||
{
|
||||
"code": "eu-ES",
|
||||
"title": "Basque (Euskara)"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
56
src/lib/utils/marked/colon-fence-extension.ts
Normal file
56
src/lib/utils/marked/colon-fence-extension.ts
Normal 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()]
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue