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

# Conflicts:
#	backend/open_webui/utils/middleware.py
This commit is contained in:
Classic298 2026-07-23 18:55:16 +02:00
commit 68d5a0eec6
544 changed files with 64420 additions and 29865 deletions

View file

@ -16,6 +16,9 @@ CORS_ALLOW_ORIGIN='*'
# Set to false to keep memory tools enabled without adding memory context to the system context.
ENABLE_MEMORY_SYSTEM_CONTEXT=true
# Set to false to disable workspace Tools and Functions.
ENABLE_PLUGINS=true
# For production you should set this to match the proxy configuration (127.0.0.1)
FORWARDED_ALLOW_IPS='*'

View file

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

View file

@ -1806,6 +1806,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
)
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = (
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true'
)
USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true'
@ -1930,6 +1933,7 @@ DEFAULT_USER_PERMISSIONS = {
},
'access_grants': {
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS,
},
'chat': {
'controls': USER_PERMISSIONS_CHAT_CONTROLS,
@ -1987,6 +1991,14 @@ ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '')
@ -2128,29 +2140,31 @@ ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lowe
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
Generate a concise title summarizing the chat history.
### Guidelines:
- The title should clearly represent the main theme or subject of the conversation.
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
- Keep it short: 2-4 words is best.
- Do not use emojis, quotation marks, or special formatting.
- Write the title in the chat's primary language; default to English if multilingual.
- Prioritize accuracy over excessive creativity; keep it clear and simple.
- Prioritize accuracy over creativity.
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
### Output:
JSON format: { "title": "your concise title here" }
### Examples:
- { "title": "📉 Stock Market Trends" },
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
- { "title": "Evolution of Music Streaming" },
- { "title": "Remote Work Productivity Tips" },
- { "title": "Artificial Intelligence in Healthcare" },
- { "title": "🎮 Video Game Development Insights" }
- { "title": "Stock Trends" },
- { "title": "Chocolate Chip Cookies" },
- { "title": "Music Streaming" },
- { "title": "Remote Work" }
### Chat History:
<chat_history>
{{MESSAGES:END:2}}
@ -3016,6 +3030,13 @@ DEFAULT_CONFIG = {
'channels.enable': ENABLE_CHANNELS,
'calendar.enable': ENABLE_CALENDAR,
'automations.enable': ENABLE_AUTOMATIONS,
'subagents.enable': ENABLE_SUBAGENTS,
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
'automations.max_count': AUTOMATION_MAX_COUNT,
'automations.min_interval': AUTOMATION_MIN_INTERVAL,
'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN,
@ -3034,6 +3055,7 @@ DEFAULT_CONFIG = {
'task.model.external': TASK_MODEL_EXTERNAL,
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,

View file

@ -1038,6 +1038,8 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
)

View file

@ -8,7 +8,7 @@ import uuid
from types import SimpleNamespace
from typing import Any
from open_webui.env import VERSION
from open_webui.env import ENABLE_PLUGINS, VERSION
from open_webui.models.config import Config
from pydantic import BaseModel, ConfigDict, Field, model_validator
from open_webui.retrieval.web.utils import validate_url
@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
CHAT_CREATED: EventDefinition = EventDefinition(
name='chat.created', description='A chat was created.', message='Chat created'
)
CHAT_FINISHED: EventDefinition = EventDefinition(
name='chat.finished', description='A chat response finished.', message='Chat finished'
)
CHAT_FAILED: EventDefinition = EventDefinition(
name='chat.failed', description='A chat response failed.', message='Chat failed'
)
CHAT_IMPORTED: EventDefinition = EventDefinition(
name='chat.imported', description='A chat was imported.', message='Chat imported'
)
@ -258,6 +264,11 @@ class EventDefinitions(BaseModel):
description='A channel member active state was updated.',
message='Channel member active updated',
)
CHANNEL_MESSAGE: EventDefinition = EventDefinition(
name='channel.message',
description='A channel message was posted.',
message='Channel message',
)
CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition(
name='channel.webhook.created',
description='A channel incoming webhook was created.',
@ -566,6 +577,11 @@ class EventDefinitions(BaseModel):
description='A calendar event RSVP was updated.',
message='Calendar Event rsvp updated',
)
CALENDAR_ALERT: EventDefinition = EventDefinition(
name='calendar.alert',
description='A calendar event alert was triggered.',
message='Calendar alert',
)
AUTOMATION_CREATED: EventDefinition = EventDefinition(
name='automation.created', description='An automation was created.', message='Automation created'
)
@ -622,6 +638,12 @@ class EventDefinitions(BaseModel):
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
)
NOTIFICATION_TEST: EventDefinition = EventDefinition(
name='notification.test', description='A notification target test was sent.', message='Notification test'
)
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
)
EVENTS = EventDefinitions()
@ -629,6 +651,12 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
EVENT_CATALOG_SET = set(EVENT_CATALOG)
NOTIFICATION_EVENTS = (
EVENTS.CHAT_FINISHED.name,
EVENTS.CHAT_FAILED.name,
EVENTS.CHANNEL_MESSAGE.name,
EVENTS.CALENDAR_ALERT.name,
)
def get_event_catalog() -> list[dict[str, str]]:
@ -1024,7 +1052,25 @@ class WebhookEventSink:
schedule_webhook_dispatch(app, event)
def schedule_notification_dispatch(app: Any, event: Event) -> None:
try:
from open_webui.utils.notifications import dispatch_notification_event
asyncio.create_task(dispatch_notification_event(app, event))
except RuntimeError:
log.exception('Notification delivery could not be scheduled for %s', event.event)
class NotificationEventSink:
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
if event.event in NOTIFICATION_EVENTS:
schedule_notification_dispatch(app, event)
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
if not ENABLE_PLUGINS:
return
from open_webui.models.functions import Functions
from open_webui.utils.plugin import get_function_module_from_cache
@ -1081,7 +1127,7 @@ class EventFunctionSink:
schedule_event_function_dispatch(app, event, request)
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
async def publish_event(
@ -1147,6 +1193,20 @@ async def publish_model_provider_request_failed(
else 'upstream_error'
)
# Server-log only; the upstream error body is otherwise invisible to admins
# (event sinks require an event function or webhook to be configured).
log.log(
logging.ERROR if status >= 500 else logging.WARNING,
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
provider,
status,
error_type,
base_url,
requested_model or '-',
error_code or '-',
error_text[:MAX_STRING_LENGTH] or '-',
)
data = {
'error_type': error_type,
'status': status,

View file

@ -20,7 +20,7 @@ from starlette.responses import Response, StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
@ -69,6 +69,9 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
async def get_function_models(request):
if not ENABLE_PLUGINS:
return []
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
pipe_models = []
@ -144,7 +147,10 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
if models is None:
models = {}
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)
@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return params
# Copy so the base-model substitution below doesn't leak into the caller's
# payload, which the tool-call continuation re-submits. Mirrors the routers.
form_data = {**form_data}
model_id = form_data.get('model')
model_info = await Models.get_model_by_id(model_id)

View file

@ -83,6 +83,7 @@ from open_webui.env import (
ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_CUSTOM_MODEL_FALLBACK,
ENABLE_EASTER_EGGS,
ENABLE_PLUGINS,
EXTERNAL_PWA_MANIFEST_URL,
# OAuth Back-Channel Logout
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
@ -153,6 +154,7 @@ from open_webui.routers import (
knowledge,
memories,
models,
notifications,
notes,
ollama,
openai,
@ -258,6 +260,15 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
async def emit_chat_list_event(metadata: dict, chat_id: str):
if not chat_id or chat_id.startswith(('channel:', 'local:')):
return
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id}})
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
@ -319,8 +330,9 @@ async def lifespan(app: FastAPI):
await migrate_legacy_webhook_config()
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
license_task = None
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY))
# Create admin account from env vars if specified and no users exist
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
@ -408,6 +420,14 @@ async def lifespan(app: FastAPI):
log.warning(f'Failed to initialize terminal servers at startup: {e}')
# Mark application as ready to accept traffic from a startup perspective.
if license_task:
try:
await asyncio.wait_for(asyncio.shield(license_task), timeout=2)
except asyncio.TimeoutError:
log.warning('License data retrieval is still pending; continuing startup without it')
except Exception as e:
log.warning(f'License data retrieval failed during startup: {e}')
app.state.startup_complete = True
await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system')
@ -751,6 +771,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
@ -1130,6 +1151,7 @@ async def chat_completion(
metadata = {
'user_id': user.id,
'user_agent': request.headers.get('user-agent', '') or '',
'internal': getattr(request.state, 'internal', False) is True,
'chat_id': form_data.pop('chat_id', None) or '',
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
@ -1266,6 +1288,7 @@ async def chat_completion(
subject_id=chat_id,
data={'title': 'New Chat'},
)
await emit_chat_list_event(metadata, chat_id)
if user_message_id:
await publish_event(
request,
@ -1367,6 +1390,7 @@ async def chat_completion(
user_message['id'],
user_message,
)
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
await publish_event(
request,
EVENTS.MESSAGE_CREATED,
@ -1378,6 +1402,15 @@ async def chat_completion(
'content_preview': user_message.get('content', '')[:300],
},
)
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
'internal'
):
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(chat_id, 'chat.user_message')
except Exception:
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
# Link grandparent → user message (childrenIds)
grandparent_id = user_message.get('parentId')
@ -1589,9 +1622,39 @@ async def chat_completion(
except Exception:
pass
try:
chat_id = metadata.get('chat_id')
if (
chat_id
and getattr(request.state, 'internal', False) is not True
and not await has_active_tasks(request.app.state.redis, chat_id)
):
from open_webui.utils.subagents import process_pending_internal_messages
await process_pending_internal_messages(
request,
chat_id,
user.id,
{
'model_id': metadata.get('model_id') or form_data.get('model'),
'session_id': metadata.get('session_id'),
'tool_ids': metadata.get('tool_ids') or [],
'skill_ids': metadata.get('skill_ids') or [],
'system_prompt': metadata.get('system_prompt'),
'filter_ids': metadata.get('filter_ids') or [],
'terminal_id': metadata.get('terminal_id'),
'features': metadata.get('features') or {},
'variables': metadata.get('variables') or {},
},
)
except Exception:
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
task_ids = []
subagent_results = []
is_internal = getattr(request.state, 'internal', False) is True
chat_id = metadata['chat_id']
for idx, entry in enumerate(message_ids):
@ -1618,28 +1681,39 @@ async def chat_completion(
# Only the first model runs chat-level background tasks;
# subsequent models only run follow-ups.
process = process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
)
if is_internal:
subagent_results.append(await process)
continue
task_id, _ = await create_task(
request.app.state.redis,
process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v
for k, v in (tasks or {}).items()
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
),
process,
id=chat_id,
)
per_model_metadata['task_id'] = task_id
task_ids.append(task_id)
if is_internal:
return {
'status': True,
'task_ids': [],
'chat_id': chat_id,
'results': subagent_results,
}
# Emit chat:active=true
if task_ids:
event_emitter = await get_event_emitter(
@ -1683,6 +1757,16 @@ from open_webui.utils.anthropic import (
)
@app.post('/api/message/count_tokens')
@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint
async def count_message_tokens(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
):
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
@app.post('/api/message')
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
async def generate_messages(
@ -1703,10 +1787,31 @@ async def generate_messages(
Authentication: Supports both standard Authorization header and
Anthropic's x-api-key header (via middleware translation).
"""
# Convert Anthropic payload to OpenAI format
requested_model = form_data.get('model', '')
input_tokens = None
try:
input_tokens = await openai.count_anthropic_tokens(request, form_data, user)
except Exception:
# Counting must not turn a compatible generation request into an outage.
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
openai_payload = convert_anthropic_to_openai_payload(form_data)
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
passthrough_params = []
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await openai.get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
passthrough_params = api_config.get('passthrough_params') or []
# Convert Anthropic payload to OpenAI format
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)
@ -1715,7 +1820,9 @@ async def generate_messages(
if isinstance(response, StreamingResponse):
# Streaming response: wrap the generator to convert SSE format
return StreamingResponse(
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model),
openai_stream_to_anthropic_stream(
response.body_iterator, model=requested_model, input_tokens=input_tokens or 0
),
media_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
@ -1723,7 +1830,7 @@ async def generate_messages(
},
)
elif isinstance(response, dict):
return convert_openai_to_anthropic_response(response, model=requested_model)
return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens)
else:
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
return response
@ -1865,6 +1972,7 @@ async def get_app_config(request: Request):
'calendar.enable',
'automations.enable',
'notes.enable',
'chat.context_compaction.enable',
'web.search.enable',
'web.search.confirmation.enable',
'web.search.confirmation.content',
@ -1927,12 +2035,14 @@ async def get_app_config(request: Request):
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
'enable_easter_eggs': ENABLE_EASTER_EGGS,
'enable_direct_connections': config.get('direct.enable'),
'enable_plugins': ENABLE_PLUGINS,
'enable_folders': config.get('folders.enable'),
'folder_max_file_count': config.get('folders.max_file_count'),
'enable_channels': config.get('channels.enable'),
'enable_calendar': config.get('calendar.enable'),
'enable_automations': config.get('automations.enable'),
'enable_notes': config.get('notes.enable'),
'enable_context_compaction': config.get('chat.context_compaction.enable'),
'enable_web_search': config.get('web.search.enable'),
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
'web_search_confirmation_content': config.get('web.search.confirmation.content'),

View file

@ -9,6 +9,8 @@ from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.models.chat_messages import ChatMessage # noqa: F401
from open_webui.models.chats import Chat # noqa: F401
from sqlalchemy import create_engine, engine_from_config, pool
alembic_config = alembic.context.config

View file

@ -0,0 +1,25 @@
"""add chat message meta
Revision ID: 856c5b02fb54
Revises: 42e2978c7933
Create Date: 2026-07-16 01:39:39.291935
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '856c5b02fb54'
down_revision: Union[str, None] = '42e2978c7933'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat_message', 'meta')

View file

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

View file

@ -1,7 +1,10 @@
import json
import time
import uuid
from collections import Counter
from datetime import datetime, timedelta
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
@ -47,6 +50,17 @@ def _normalize_timestamp(timestamp: int) -> float:
return timestamp
def _timezone(tz: Optional[str]) -> ZoneInfo:
try:
return ZoneInfo(tz or 'UTC')
except ZoneInfoNotFoundError:
return ZoneInfo('UTC')
def _date_key(timestamp: int, tz: ZoneInfo) -> str:
return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d')
def get_usage(data: dict) -> Optional[dict]:
"""Extract and normalize usage from message data."""
usage = data.get('usage') or (data.get('info') or {}).get('usage')
@ -72,6 +86,40 @@ def _token_columns(dialect: str):
)
def _extract_tool_names(value: Any) -> list[str]:
names: list[str] = []
def add(name: Any):
if isinstance(name, str):
cleaned = name.strip()
if cleaned and len(cleaned) <= 128:
names.append(cleaned)
def walk(item: Any):
if isinstance(item, list):
for child in item:
walk(child)
return
if not isinstance(item, dict):
return
item_type = str(item.get('type') or '')
looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'}
if looks_like_tool:
add(item.get('name') or item.get('tool_name'))
function = item.get('function')
if isinstance(function, dict):
add(function.get('name'))
for key in ('tool_calls', 'tools', 'output', 'meta'):
if key in item:
walk(item.get(key))
walk(value)
return names
####################
# ChatMessage DB Schema
####################
@ -100,6 +148,7 @@ class ChatMessage(Base):
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
@ -142,6 +191,7 @@ class ChatMessageModel(BaseModel):
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
meta: Optional[dict] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict | str] = None
@ -192,6 +242,8 @@ class ChatMessageTable:
existing.sources = data.get('sources')
if 'embeds' in data:
existing.embeds = data.get('embeds')
if 'meta' in data:
existing.meta = data.get('meta')
if 'done' in data:
existing.done = data.get('done', True)
if 'status_history' in data or 'statusHistory' in data:
@ -225,6 +277,7 @@ class ChatMessageTable:
files=data.get('files'),
sources=data.get('sources'),
embeds=data.get('embeds'),
meta=data.get('meta'),
done=data.get('done', True),
status_history=data.get('status_history') or data.get('statusHistory'),
error=data.get('error'),
@ -243,6 +296,21 @@ class ChatMessageTable:
message = await db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
async def has_unfinished_assistant_by_chat_id(
self,
chat_id: str,
db: Optional[AsyncSession] = None,
) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage.id)
.where(ChatMessage.chat_id == chat_id)
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
.limit(1)
)
return result.scalar_one_or_none() is not None
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
@ -584,6 +652,233 @@ class ChatMessageTable:
for row in result.all()
}
async def get_user_usage_summary(
self,
user_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
include_active_days: bool = True,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.user_id == user_id,
)
token_stmt = select(
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.usage.isnot(None),
)
models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date)
token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date)
models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date)
token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date)
models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date)
messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role))
message_counts = {row.role: row.count for row in messages_result.all()}
token_result = (await db.execute(token_stmt)).one()
models_used = (await db.execute(models_stmt)).scalar() or 0
active_days = set()
if include_active_days:
tz = _timezone(timezone)
day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id)
if start_date:
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
day_result = await db.execute(day_stmt)
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
input_total = int(token_result.input_tokens or 0)
output_total = int(token_result.output_tokens or 0)
return {
'messages': sum(message_counts.values()),
'user_messages': message_counts.get('user', 0),
'assistant_messages': message_counts.get('assistant', 0),
'input_tokens': input_total,
'output_tokens': output_total,
'total_tokens': input_total + output_total,
'models_used': int(models_used),
'active_days': len(active_days),
}
async def get_user_first_message_created_at(
self,
user_id: str,
db: Optional[AsyncSession] = None,
) -> Optional[int]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(func.min(ChatMessage.created_at)).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at.isnot(None),
)
)
value = result.scalar()
return int(value) if value else None
async def get_user_daily_usage(
self,
user_id: str,
start_date: int,
end_date: int,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
tz = _timezone(timezone)
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = select(
ChatMessage.created_at,
ChatMessage.chat_id,
ChatMessage.role,
ChatMessage.model_id,
ChatMessage.usage,
input_tokens.label('input_tokens'),
output_tokens.label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
daily: dict[str, dict] = {}
for row in result.all():
date = _date_key(row.created_at, tz)
entry = daily.setdefault(
date,
{
'date': date,
'messages': 0,
'chat_ids': set(),
'tokens': 0,
'models': Counter(),
},
)
entry['messages'] += 1
entry['chat_ids'].add(row.chat_id)
if row.role == 'assistant' and row.model_id:
entry['models'][row.model_id] += 1
if row.usage:
entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0)
current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
while current <= end_dt:
date = current.strftime('%Y-%m-%d')
daily.setdefault(
date,
{'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()},
)
current += timedelta(days=1)
return [
{
'date': item['date'],
'messages': item['messages'],
'chats': len(item['chat_ids']),
'tokens': item['tokens'],
'models': dict(item['models']),
}
for item in sorted(daily.values(), key=lambda x: x['date'])
]
async def get_user_top_models(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = (
select(
ChatMessage.model_id,
func.count(ChatMessage.id).label('messages'),
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
)
.filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
.group_by(ChatMessage.model_id)
.order_by(func.count(ChatMessage.id).desc())
.limit(limit)
)
result = await db.execute(stmt)
return [
{
'model_id': row.model_id,
'messages': row.messages,
'input_tokens': int(row.input_tokens or 0),
'output_tokens': int(row.output_tokens or 0),
'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0),
}
for row in result.all()
]
async def get_user_top_tools(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
stmt = select(ChatMessage.output, ChatMessage.meta).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
counts: Counter[str] = Counter()
for output, meta in result.all():
for name in _extract_tool_names(output):
counts[name] += 1
for name in _extract_tool_names(meta):
counts[name] += 1
return [{'name': name, 'count': count} for name, count in counts.most_common(limit)]
async def get_message_count_by_user(
self,
start_date: Optional[int] = None,

View file

@ -39,6 +39,7 @@ from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import bindparam
log = logging.getLogger(__name__)
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
class Chat(Base): # database table mapping for chat entity
@ -61,6 +62,7 @@ class Chat(Base): # database table mapping for chat entity
tasks = Column(JSON, nullable=True)
summary = Column(Text, nullable=True)
current_message_id = Column(Text, nullable=True)
last_read_at = Column(BigInteger, nullable=True)
@ -74,6 +76,10 @@ class Chat(Base): # database table mapping for chat entity
)
def is_internal_chat(meta: dict | None) -> bool:
return bool(meta and meta.get('internal') is True)
class ChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
id: str
@ -93,6 +99,7 @@ class ChatModel(BaseModel):
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
last_read_at: int | None = None
@ -140,6 +147,7 @@ class ChatForm(BaseModel):
class ChatImportForm(ChatForm):
meta: dict | None = {}
pinned: bool | None = False
current_message_id: str | None = None
created_at: int | None = None
updated_at: int | None = None
@ -172,6 +180,8 @@ class ChatResponse(BaseModel):
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
context_usage: dict | None = None
class ChatTitleIdResponse(BaseModel):
@ -181,6 +191,7 @@ class ChatTitleIdResponse(BaseModel):
created_at: int
last_read_at: int | None = None
snippet: str | None = None
active: bool = False
class SharedChatResponse(BaseModel):
@ -273,6 +284,21 @@ class ChatTable:
"""Recursively remove null bytes from strings in dict/list structures."""
return sanitize_data_for_db(obj)
def get_current_message_id(self, chat: dict | None) -> str | None:
chat = chat or {}
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
if current_id:
return current_id
messages = chat.get('messages')
if isinstance(messages, list):
for message in reversed(messages):
if isinstance(message, dict) and message.get('id'):
return message['id']
return None
def _sanitize_chat_row(self, chat_item):
"""
Clean a Chat SQLAlchemy model's title + chat JSON,
@ -349,7 +375,13 @@ class ChatTable:
return True
async def insert_new_chat(
self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None
self,
id: str,
user_id: str,
form_data: ChatForm,
db: AsyncSession | None = None,
*,
internal_meta: dict | None = None,
) -> ChatModel | None:
async with get_async_db_context(db) as session:
chat = ChatModel(
@ -361,6 +393,8 @@ class ChatTable:
),
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'current_message_id': self.get_current_message_id(form_data.chat),
'created_at': int(time.time()),
'updated_at': int(time.time()),
'last_read_at': int(time.time()),
@ -374,8 +408,14 @@ class ChatTable:
# Dual-write initial messages to chat_message table
try:
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
await ChatMessages.upsert_message(
@ -389,6 +429,50 @@ class ChatTable:
return ChatModel.model_validate(chat_item) if chat_item else None
async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]:
async with get_async_db_context() as session:
result = await session.execute(
select(Chat.id).where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['parent_chat_id'].as_string() == parent_chat_id,
)
)
return list(result.scalars().all())
async def get_internal_chat_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> ChatModel | None:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat)
.where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
chat = result.scalars().first()
return ChatModel.model_validate(chat) if chat else None
async def get_internal_chats_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat)
.where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
id = str(uuid.uuid4())
chat = ChatModel(
@ -400,6 +484,7 @@ class ChatTable:
'meta': form_data.meta,
'pinned': form_data.pinned,
'folder_id': form_data.folder_id,
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
}
@ -439,8 +524,14 @@ class ChatTable:
# Dual-write messages to chat_message table
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
try:
@ -460,6 +551,8 @@ class ChatTable:
id: str,
chat: dict,
db: AsyncSession | None = None,
*,
touch: bool = True,
) -> ChatModel | None:
"""Persist updated chat content, sanitizing null bytes."""
try: # load the chat record for in-place mutation
@ -470,8 +563,11 @@ class ChatTable:
chat_item.chat = self._clean_null_bytes(chat)
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
chat_item.current_message_id = self.get_current_message_id(chat)
chat_item.updated_at = int(time.time())
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
@ -702,7 +798,7 @@ class ChatTable:
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
async def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict
self, id: str, message_id: str, message: dict, *, touch: bool = True
) -> ChatModel | None:
chat = await self.get_chat_by_id(id)
if chat is None:
@ -771,7 +867,7 @@ class ChatTable:
except Exception as e:
log.warning(f'Failed to write to chat_message table: {e}')
return await self.update_chat_by_id(id, chat)
return await self.update_chat_by_id(id, chat, touch=touch)
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
chat_model = await self.get_chat_by_id(id)
@ -809,7 +905,7 @@ class ChatTable:
history['messages'][message_id]['statusHistory'] = status_history
chat['history'] = history
return await self.update_chat_by_id(id, chat)
return await self.update_chat_by_id(id, chat, touch=False)
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
async with get_async_db_context() as session:
@ -951,6 +1047,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
user_id=user_id, archived=True
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
query_key = filter.get('query')
@ -998,7 +1095,8 @@ class ChatTable:
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True))
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
return result.scalar() or 0
async def get_shared_chat_list_by_user_id(
@ -1027,6 +1125,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_archived:
stmt = stmt.filter_by(archived=False)
@ -1082,6 +1181,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_folders:
stmt = stmt.filter_by(folder_id=None)
@ -1123,9 +1223,9 @@ class ChatTable:
db: AsyncSession | None = None,
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc())
)
stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
@ -1170,6 +1270,7 @@ class ChatTable:
select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name'))
.join(chat_ids, chat_ids.c.chat_id == Chat.id)
.outerjoin(User, User.id == Chat.user_id)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
order_by = filter.get('order_by') if filter else None
@ -1301,10 +1402,41 @@ class ChatTable:
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat).order_by(Chat.updated_at.desc()))
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict:
async with get_async_db_context(db) as session:
chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter))
total_chats = int(result.scalar() or 0)
messages_stmt = (
select(ChatMessage.chat_id, ChatMessage.created_at)
.join(Chat, Chat.id == ChatMessage.chat_id)
.where(*chat_filter, ChatMessage.created_at.isnot(None))
.order_by(ChatMessage.chat_id, ChatMessage.created_at.asc())
)
messages_result = await session.execute(messages_stmt)
last_message_at_by_chat: dict[str, int] = {}
active_seconds_by_chat: dict[str, int] = {}
for chat_id, created_at in messages_result.all():
timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at)
last_message_at = last_message_at_by_chat.get(chat_id)
if last_message_at is not None:
delta = timestamp - last_message_at
if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS:
active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta
last_message_at_by_chat[chat_id] = timestamp
return {
'total_chats': total_chats,
'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0),
}
# list user conversations
async def get_chats_by_user_id(
self,
@ -1316,6 +1448,7 @@ class ChatTable:
) -> ChatListResponse:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter_by(user_id=user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
if filter.get('updated_at'):
@ -1359,11 +1492,11 @@ class ChatTable:
self, user_id: str, db: AsyncSession | None = None
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(user_id=user_id, pinned=True, archived=False)
.order_by(Chat.updated_at.desc())
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id, pinned=True, archived=False
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
@ -1380,9 +1513,9 @@ class ChatTable:
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc())
)
stmt = select(Chat).filter_by(user_id=user_id, archived=True)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
# search user conversations
@ -1453,6 +1586,7 @@ class ChatTable:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter(Chat.user_id == user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if is_archived is not None:
stmt = stmt.filter(Chat.archived == is_archived)
@ -1528,9 +1662,23 @@ class ChatTable:
postgres_content_sql = """
EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS message
WHERE json_typeof(message->'content') = 'string'
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
FROM chat_message AS message
WHERE message.chat_id = Chat.id
AND message.user_id = Chat.user_id
AND json_typeof(message.content) = 'string'
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
WHERE json_typeof(history_message.value->'content') = 'string'
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
WHERE json_typeof(legacy_message->'content') = 'string'
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
)
"""
@ -1594,6 +1742,7 @@ class ChatTable:
.filter_by(folder_id=folder_id, user_id=user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
)
@ -1622,16 +1771,21 @@ class ChatTable:
folder_id: str,
skip: int = 0,
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.order_by(Chat.updated_at.desc(), Chat.id)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(order_clause, Chat.id)
)
if skip:
@ -1653,6 +1807,22 @@ class ChatTable:
for chat in all_chats
]
async def count_all_chats_by_folder_id(
self,
folder_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
stmt = (
select(func.count(Chat.id))
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
@ -1662,6 +1832,7 @@ class ChatTable:
.filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc())
)
@ -1707,6 +1878,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
@ -1769,6 +1941,7 @@ class ChatTable:
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
@ -1816,7 +1989,8 @@ class ChatTable:
self, folder_id: str, user_id: str, db: AsyncSession | None = None
) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id))
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folder '{folder_id}': {count}")
@ -1829,9 +2003,8 @@ class ChatTable:
return 0
async with get_async_db_context(db) as session:
result = await session.execute(
select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
)
stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folders '{folder_ids}': {count}")

View file

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

View file

@ -7,7 +7,7 @@ import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import UserResponse, Users
from open_webui.models.users import User, UserResponse, Users, UserSettings
from open_webui.utils.valves import decrypt_valves, encrypt_valves
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
@ -275,6 +275,12 @@ class FunctionsTable:
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active filters without fetching plugin source."""
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.is_global).filter_by(type='filter', is_active=True))
return [(id, bool(is_global)) for id, is_global in result.all()]
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
@ -283,13 +289,15 @@ class FunctionsTable:
async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
async with get_async_db_context(db) as db:
try:
function = await db.get(Function, id)
return decrypt_valves(function.valves if function else None)
result = await db.execute(select(Function.valves).filter_by(id=id))
return decrypt_valves(result.scalar_one_or_none())
except Exception as e:
log.exception(f'Error getting function valves by id {id}: {e}')
return None
async def get_function_valves_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> dict[str, dict]:
async def get_function_valves_by_ids(
self, ids: list[str], db: AsyncSession | None = None
) -> dict[str, dict]:
"""
Batch fetch valves for multiple functions in a single query.
Returns a dict mapping function_id -> valves dict.
@ -300,8 +308,7 @@ class FunctionsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
functions = result.all()
return {f.id: decrypt_valves(f.valves) for f in functions}
return {id: decrypt_valves(valves) for id, valves in result.all()}
except Exception as e:
log.exception(f'Error batch-fetching function valves: {e}')
return {}
@ -347,8 +354,11 @@ class FunctionsTable:
self, id: str, user_id: str, db: AsyncSession | None = None
) -> dict | None:
try:
user = await Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
async with get_async_db_context(db) as db:
result = await db.execute(select(User.settings).filter_by(id=user_id))
settings = result.scalar_one_or_none()
user_settings = UserSettings(**settings).model_dump() if settings else {}
# Check if user has "functions" and "valves" settings
if 'functions' not in user_settings:

View file

@ -149,6 +149,7 @@ class KnowledgeDirectoryForm(BaseModel):
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel):
@ -322,6 +323,14 @@ class KnowledgeTable:
knowledge_ids = [kb.id for kb, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
file_counts = {}
if knowledge_ids:
file_count_result = await db.execute(
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
.group_by(KnowledgeFile.knowledge_id)
)
file_counts = dict(file_count_result.all())
knowledge_bases = []
for knowledge_base, user in items:
@ -336,6 +345,7 @@ class KnowledgeTable:
)
).model_dump(),
'user': (UserModel.model_validate(user).model_dump() if user else None),
'file_count': file_counts.get(knowledge_base.id, 0),
}
)
)

View file

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

View file

@ -312,6 +312,9 @@ class NoteTable:
if 'meta' in form_data:
note.meta = {**note.meta, **form_data['meta']}
if not db.is_modified(note) and 'access_grants' not in form_data:
return await self._to_note_model(note, db=db)
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)

View file

@ -259,7 +259,26 @@ class SkillsTable:
permission='read',
)
stmt = stmt.order_by(Skill.updated_at.desc())
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Skill.name.asc())
else:
stmt = stmt.order_by(Skill.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.created_at.asc())
else:
stmt = stmt.order_by(Skill.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.updated_at.asc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))

View file

@ -37,6 +37,7 @@ from open_webui.env import (
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.folders import Folders
from open_webui.models.knowledge import Knowledges
from open_webui.models.notes import Notes
from open_webui.models.config import Config
@ -48,6 +49,7 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.main import GetResult, SearchResult
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
@ -1323,6 +1325,23 @@ async def get_sources_from_items(
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
extracted_collections = []
query_results = []
folder_items = set()
expanded_folders = set()
items = list(items)
for item in items:
if item.get('type') != 'folder' or not user:
continue
folder_id = item.get('id')
if not folder_id or folder_id in expanded_folders:
continue
expanded_folders.add(folder_id)
folder = await Folders.get_folder_by_id(folder_id)
if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)):
files = (folder.data or {}).get('files', [])
folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict))
items.extend(files)
for item in items:
query_result = None
@ -1429,6 +1448,7 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(item.get('id'), 'read', user)
or ('file', item.get('id')) in folder_items
):
query_result = {
'documents': [[file_object.data.get('content', '')]],
@ -1459,6 +1479,7 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(file_id, 'read', user)
or ('file', file_id) in folder_items
):
if item.get('legacy'):
collection_names.append(f'{file_id}')
@ -1478,6 +1499,7 @@ async def get_sources_from_items(
resource_id=knowledge_base.id,
permission='read',
)
or ('collection', item.get('id')) in folder_items
):
if (knowledge_base.meta or {}).get('source') == 'external':
query_result = await retrieve_external_knowledge(
@ -1500,6 +1522,7 @@ async def get_sources_from_items(
resource_id=knowledge_base.id,
permission='read',
)
or ('collection', item.get('id')) in folder_items
):
files = await Knowledges.get_files_by_id(knowledge_base.id)
@ -1570,7 +1593,7 @@ async def get_sources_from_items(
continue
# Filter out collections the user cannot read
if user:
if user and (item.get('type'), item.get('id')) not in folder_items:
collection_names = await filter_accessible_collections(collection_names, user)
if not collection_names:
log.debug(f'access denied for all collections in item {item}')

View file

@ -146,7 +146,17 @@ class MilvusClient(VectorDBBase):
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
collection.create_index('vector', index_params)
collection.create_index(RESOURCE_ID_FIELD)
try:
# A Milvus server auto-selects the scalar index type; embedded
# Milvus Lite requires an explicit one.
collection.create_index(RESOURCE_ID_FIELD)
except MilvusException:
try:
collection.create_index(RESOURCE_ID_FIELD, {'index_type': 'INVERTED'})
except MilvusException as e:
# The index only accelerates resource_id filters; never fail
# collection creation over it.
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
log.info(f'Created shared collection: {mt_collection_name}')
return collection

View file

@ -51,7 +51,6 @@ from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.utils.webhook import post_webhook
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
@ -915,7 +914,6 @@ async def get_pinned_channel_messages(
async def send_notification(request, channel, message, active_user_ids, db=None):
name = request.app.state.WEBUI_NAME
webui_url = await Config.get('webui.url')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
# Batch fetch channel members in 1 query (fixes N+1)
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
url = f'{webui_url}/channels/{channel.id}'
for u in users:
if (u.id not in active_user_ids) and u.id in member_ids:
if enable_user_webhooks and u.settings:
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
if webhook_url:
await post_webhook(
name,
webhook_url,
f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}',
{
'action': 'channel',
'message': message.content,
'title': channel.name,
'url': f'{webui_url}/channels/{channel.id}',
},
)
await publish_event(
request,
EVENTS.CHANNEL_MESSAGE,
subject_id=channel.id,
subject_type='channel',
data={
'user_id': u.id,
'channel_id': channel.id,
'message_id': message.id,
'sender_id': message.user_id,
'content': message.content,
'message': f'#{channel.name} - {url}\n\n{message.content}',
'content_preview': message.content[:300],
'title': channel.name,
'url': url,
},
message=channel.name,
)
return True

View file

@ -14,6 +14,7 @@ from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import (
AggregateChatStats,
ChatBody,
@ -26,6 +27,7 @@ from open_webui.models.chats import (
ChatStatsExport,
ChatTitleIdResponse,
ChatUsageStatsListResponse,
is_internal_chat,
MessageStats,
)
from open_webui.models.folders import Folders
@ -36,7 +38,8 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.context_compaction import compact_chat_branch
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
from open_webui.utils.misc import get_message_list
from open_webui.utils.models import get_all_models
from pydantic import BaseModel
@ -51,13 +54,28 @@ SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
CHAT_CONFIG_KEYS = {
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
}
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
for chat in chat_list:
chat.active = False
if not await has_active_tasks(request.app.state.redis, chat.id):
continue
chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
return chat_list
class ChatConfigForm(BaseModel):
ENABLE_CONTEXT_COMPACTION: bool
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
@ -104,7 +122,10 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
async def get_chat_config_values() -> dict:
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
return config
def chat_config_updates(data: dict) -> dict:
@ -131,6 +152,7 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio
@router.get('/', response_model=list[ChatTitleIdResponse])
@router.get('/list', response_model=list[ChatTitleIdResponse])
async def get_session_user_chat_list(
request: Request,
user=Depends(get_verified_user),
page: int | None = None,
include_pinned: bool | None = False,
@ -142,7 +164,7 @@ async def get_session_user_chat_list(
limit = 60
skip = (page - 1) * limit
return await Chats.get_chat_title_id_list_by_user_id(
chats = await Chats.get_chat_title_id_list_by_user_id(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
@ -151,12 +173,13 @@ async def get_session_user_chat_list(
db=db,
)
else:
return await Chats.get_chat_title_id_list_by_user_id(
chats = await Chats.get_chat_title_id_list_by_user_id(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@ -599,6 +622,7 @@ async def delete_all_user_chats(
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_user_id(
request: Request,
user_id: str,
page: int | None = None,
query: str | None = None,
@ -623,9 +647,10 @@ async def get_user_chat_list_by_user_id(
if direction:
filter['direction'] = direction
return await Chats.get_chat_list_by_user_id(
chats = await Chats.get_chat_list_by_user_id(
user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
)
return await add_active_state_to_chat_list(request, chats)
############################
@ -712,11 +737,13 @@ async def get_chat_config(user=Depends(get_admin_user)):
@router.post('/config', response_model=ChatConfigForm)
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
await Config.upsert(
chat_config_updates(
{
**form_data.model_dump(),
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
}
)
)
@ -730,6 +757,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
@router.get('/search', response_model=list[ChatTitleIdResponse])
async def search_user_chats(
request: Request,
text: str,
page: int | None = None,
user=Depends(get_verified_user),
@ -755,7 +783,7 @@ async def search_user_chats(
log.debug(f'deleting tag: {tag_id}')
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
return chat_list
return await add_active_state_to_chat_list(request, chat_list)
############################
@ -778,8 +806,9 @@ async def get_chats_by_folder_id(
]
@router.get('/folder/{folder_id}/list')
@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
async def get_chat_list_by_folder_id(
request: Request,
folder_id: str,
page: int | None = 1,
user=Depends(get_verified_user),
@ -790,10 +819,7 @@ async def get_chat_list_by_folder_id(
skip = (page - 1) * limit
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
return [
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
for chat in chats
]
return await add_active_state_to_chat_list(request, chats)
except Exception as e:
log.exception(e)
@ -806,8 +832,11 @@ async def get_chat_list_by_folder_id(
@router.get('/pinned', response_model=list[ChatTitleIdResponse])
async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return await Chats.get_pinned_chats_by_user_id(user.id, db=db)
async def get_user_pinned_chats(
request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
return await add_active_state_to_chat_list(request, chats)
############################
@ -900,6 +929,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio
@router.get('/archived', response_model=list[ChatTitleIdResponse])
async def get_archived_session_user_chat_list(
request: Request,
page: int | None = None,
query: str | None = None,
order_by: str | None = None,
@ -921,13 +951,14 @@ async def get_archived_session_user_chat_list(
if direction:
filter['direction'] = direction
return await Chats.get_archived_chat_list_by_user_id(
chats = await Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
############################
@ -1095,6 +1126,7 @@ class TagFilterForm(TagForm):
@router.post('/tags', response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_tag_name(
request: Request,
form_data: TagFilterForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@ -1105,7 +1137,7 @@ async def get_user_chat_list_by_tag_name(
if len(chats) == 0:
await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
return chats
return await add_active_state_to_chat_list(request, chats)
############################
@ -1136,7 +1168,8 @@ async def compact_chat_by_id(
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id)
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
current_message_id = chat.current_message_id or history.get('currentId')
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
model_id = (form_data.model if form_data else None) or next(
(message.get('model') for message in reversed(message_list) if message.get('model')),
None,
@ -1149,6 +1182,7 @@ async def compact_chat_by_id(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
result['context_usage'] = await get_chat_context_usage(chat, model_id)
if result.get('compacted'):
await publish_event(
request,
@ -1171,8 +1205,10 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
if user.role == 'admin':
candidate = await Chats.get_chat_by_id(id, db=db)
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
chat = candidate
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
@ -1193,7 +1229,9 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
chat = candidate
if chat:
return ChatResponse(**chat.model_dump())
data = ChatResponse(**chat.model_dump()).model_dump()
data['context_usage'] = await get_chat_context_usage(chat)
return data
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1423,6 +1461,13 @@ async def delete_chat_by_id(
# before deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
async def delete_internal_children(owner_id: str) -> None:
child_ids = await Chats.get_internal_chat_ids_by_parent_id(id, owner_id)
for child_id in child_ids:
await stop_item_tasks(request.app.state.redis, child_id)
await Chats.delete_chat_by_id_and_user_id(child_id, owner_id)
await stop_item_tasks(request.app.state.redis, id)
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
if not chat:
@ -1431,6 +1476,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(chat.user_id)
result = await Chats.delete_chat_by_id(id, db=db)
@ -1457,6 +1503,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(user.id)
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
if result:
@ -1519,6 +1566,96 @@ class CloneForm(BaseModel):
title: str | None = None
class ForkForm(BaseModel):
message_id: str | None = None
@router.post('/{id}/fork', response_model=ChatResponse | None)
async def fork_chat_by_id(
request: Request,
id: str,
form_data: ForkForm | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
if await has_active_tasks(request.app.state.redis, id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
if any(
message.get('role') == 'assistant' and message.get('done') is False
for message in messages_map.values()
if isinstance(message, dict)
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
source_message_id = (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
if not source_message_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
try:
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
except ValueError as exc:
detail = str(exc)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
detail=detail,
) from exc
updated_chat = {**(chat.chat or {})}
updated_chat.pop('currentId', None)
updated_chat.update(
{
'originalChatId': chat.id,
'branchPointMessageId': source_message_id,
'title': f'{chat.title} (fork)',
'history': fork_history,
'messages': fork_messages,
}
)
meta = {
**(chat.meta or {}),
'forked_from': chat.id,
'forked_from_message_id': source_message_id,
}
fork = await Chats.insert_new_chat(
str(uuid4()),
user.id,
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
db=db,
internal_meta=meta,
)
if not fork:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
if chat.pinned:
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
await publish_event(
request,
EVENTS.CHAT_CLONED,
actor=user,
subject_id=fork.id,
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
)
return ChatResponse(**fork.model_dump())
@router.post('/{id}/clone', response_model=ChatResponse | None)
async def clone_chat_by_id(
request: Request,

View file

@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = {
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
'DEFAULT_MODEL_PARAMS': 'models.default_params',
}
SUBAGENTS_CONFIG_KEYS = {
'ENABLE_SUBAGENTS': 'subagents.enable',
'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled',
'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent',
'SUBAGENTS_MAX_ASYNC': 'subagents.max_async',
'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations',
'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output',
'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel):
config: dict | None = None
# Orchestrator policy fields
server_type: str | None = None # "orchestrator", "terminal"
server_type: str | None = None
policy_id: str | None = None
policy: dict | None = None # cached policy data
model_config = ConfigDict(extra='allow')
@ -321,7 +328,9 @@ async def set_terminal_servers_config(
form_data: TerminalServersConfigForm,
user=Depends(get_admin_user),
):
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
connections = [
connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS
]
await Config.upsert({'terminal_server.connections': connections})
await set_terminal_servers(request)
@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel):
key: str | None = ''
auth_type: str | None = 'bearer'
policy_id: str
policy_data: dict
policy_data: dict | None = None
class TerminalServerLifecycleForm(BaseModel):
@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel):
key: str | None = ''
auth_type: str | None = 'bearer'
policy_id: str
lifecycle_data: dict
lifecycle_data: dict | None = None
class TerminalServerRefreshForm(BaseModel):
@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel):
async def put_terminal_server_policy(
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
):
"""
Proxy a policy PUT to an orchestrator terminal server.
"""
"""Proxy a policy read or update to an orchestrator terminal server."""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
@ -433,8 +440,12 @@ async def put_terminal_server_policy(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
async with session.put(
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
async with session.request(
'GET' if form_data.policy_data is None else 'PUT',
policy_url,
headers=headers,
json=form_data.policy_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
return await resp.json()
@ -443,17 +454,15 @@ async def put_terminal_server_policy(
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save policy to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
log.debug(f'Failed to access policy on terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
@router.post('/terminal_servers/lifecycle')
async def put_terminal_server_lifecycle(
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
):
"""
Proxy a policy lifecycle PUT to an orchestrator terminal server.
"""
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
async with session.put(
async with session.request(
'GET' if form_data.lifecycle_data is None else 'PUT',
lifecycle_url,
headers=headers,
json=form_data.lifecycle_data,
@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle(
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save lifecycle to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
log.debug(f'Failed to access lifecycle on terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
@router.post('/terminal_servers/refresh')
@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user=
return values
class SubagentsConfigForm(BaseModel):
ENABLE_SUBAGENTS: bool
SUBAGENTS_BACKGROUND_ENABLED: bool
SUBAGENTS_MAX_CONCURRENT: int
SUBAGENTS_MAX_ASYNC: int
SUBAGENTS_MAX_ITERATIONS: int
SUBAGENTS_MAX_OUTPUT: int
SUBAGENTS_SYSTEM_PROMPT: str
@router.get('/subagents', response_model=SubagentsConfigForm)
async def get_subagents_config(user=Depends(get_admin_user)):
return await get_config_values(SUBAGENTS_CONFIG_KEYS)
@router.post('/subagents', response_model=SubagentsConfigForm)
async def set_subagents_config(
request: Request,
form_data: SubagentsConfigForm,
user=Depends(get_admin_user),
):
await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS))
values = await get_config_values(SUBAGENTS_CONFIG_KEYS)
await publish_event(
request,
EVENTS.CONFIG_UPDATED,
actor=user,
subject_id='subagents',
subject_type='config',
data={'enabled': values.get('ENABLE_SUBAGENTS')},
)
return values
class PromptSuggestion(BaseModel):
title: list[str]
content: str

View file

@ -201,21 +201,28 @@ async def process_uploaded_file(
f'{knowledge_id}: user {user.id} lacks write access'
)
else:
await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
)
# Keep the generic file status stream open until the
# KB-specific vector write and durable link both finish.
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
await process_file(
request,
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
user=user,
db=db_session,
)
knowledge_file = await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
db=db_session,
)
if not knowledge_file:
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
except Exception as e:
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
raise
except Exception as e:
log.error(f'Error processing file: {file_item.id}')

View file

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

View file

@ -10,7 +10,7 @@ import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.functions import (
@ -46,11 +46,17 @@ router = APIRouter()
@router.get('/', response_model=list[FunctionResponse])
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
return await Functions.get_functions(db=db)
@router.get('/list', response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
return await Functions.get_function_list(db=db)
@ -65,6 +71,9 @@ async def get_functions(
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
if not ENABLE_PLUGINS:
return []
return await Functions.get_functions(include_valves=include_valves, db=db)

View file

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

View file

@ -119,7 +119,11 @@ async def _verify_knowledge_file_access(
PAGE_ITEM_COUNT = 30
@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py
@router.get(
'/list',
response_model=ModelAccessListResponse,
response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
) # do NOT use "/" as path, conflicts with main.py
async def get_models(
query: str | None = None,
view_option: str | None = None,
@ -415,7 +419,10 @@ async def import_models(
continue
# Update existing model
model_data['meta'] = model_data.get('meta', {})
model_data['meta'] = {
**existing_model.meta.model_dump(),
**(model_data.get('meta') or {}),
}
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
@ -721,6 +728,9 @@ async def update_model_by_id(
if 'base_model_id' not in form_data.model_fields_set:
form_data.base_model_id = model.base_model_id
if 'profile_image_url' not in form_data.meta.model_fields_set:
form_data.meta.profile_image_url = model.meta.profile_image_url
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,

View file

@ -1,6 +1,7 @@
import json
import logging
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from open_webui.config import (
@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import ChatForm, ChatResponse, Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.notes import (
@ -303,6 +305,229 @@ async def get_note_by_id(
)
@router.get('/{id}/chat', response_model=ChatResponse)
async def get_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
if chat:
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
payload = {**(chat.chat or {})}
params = {**(payload.get('params') or {})}
changed = False
if params.pop('note_id', None) is not None:
changed = True
system = (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
if params.get('system') != system:
params['system'] = system
changed = True
if payload.pop('system', None) is not None:
changed = True
payload['params'] = params
if changed:
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
if updated_chat:
return updated_chat
return chat
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {
'system': (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat
@router.get('/{id}/chats', response_model=list[ChatResponse])
async def get_note_chats_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
normalized_chats = []
for chat in chats:
payload = {**(chat.chat or {})}
params = {**(payload.get('params') or {})}
changed = False
if params.pop('note_id', None) is not None:
changed = True
system = (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
if params.get('system') != system:
params['system'] = system
changed = True
if payload.pop('system', None) is not None:
changed = True
payload['params'] = params
if changed:
chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat
normalized_chats.append(chat)
return normalized_chats
@router.post('/{id}/chat', response_model=ChatResponse)
async def create_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {
'system': (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat
############################
# UpdateNoteById
############################
@ -354,9 +579,15 @@ async def update_note_by_id(
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
event_data = note.model_dump()
if form_data.data is not None:
event_data['data'] = {
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
}
await sio.emit(
'note-events',
note.model_dump(),
'events:note',
event_data,
to=f'note:{note.id}',
)

View file

@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.config import Config
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.notifications import (
create_target,
delete_target,
get_notification_event_catalog,
list_targets,
set_default_target,
test_target,
update_target,
)
router = APIRouter()
class NotificationTargetForm(BaseModel):
id: str | None = None
type: str | None = None
enabled: bool | None = None
events: list[str] | None = None
delivery: str | None = None
config: dict[str, Any] | None = None
async def _check_notifications_access(user) -> None:
if not await Config.get('ui.enable_user_webhooks'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and not await has_permission(
user.id, 'features.webhooks', await Config.get('user.permissions')
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
@router.get('/events')
async def get_notification_events(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return {'events': get_notification_event_catalog()}
@router.get('/targets')
async def get_notification_targets(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return await list_targets(user.id)
@router.post('/targets')
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await create_target(user.id, form_data.model_dump(exclude_unset=True))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.put('/targets/{target_id}')
async def update_notification_target(
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
):
await _check_notifications_access(user)
try:
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete('/targets/{target_id}')
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
if not await delete_target(user.id, target_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
return {'ok': True}
@router.put('/targets/{target_id}/default')
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await set_default_target(user.id, target_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post('/targets/{target_id}/test')
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
return await test_target(user.id, target_id, app_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

View file

@ -313,6 +313,16 @@ async def update_config(
'ollama.api_configs': api_configs,
}
)
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OLLAMA_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
await publish_event(
request,
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
@ -355,6 +365,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
return api_configs.get(str(idx), api_configs.get(url, {}))
async def get_ollama_connection_config() -> tuple[list, dict]:
"""Base URLs and per-connection API configs in one batched SELECT."""
config = await Config.get_many('ollama.base_urls', 'ollama.api_configs')
return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {})
@cached(
ttl=MODELS_CACHE_TTL,
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
@ -871,12 +887,10 @@ async def embed(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -925,12 +939,10 @@ async def embeddings(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -984,11 +996,9 @@ async def generate_completion(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -997,7 +1007,7 @@ async def generate_completion(
return await send_request(
f'{url}/api/generate',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=True,
)
@ -1119,7 +1129,8 @@ async def generate_chat_completion(
await check_model_access(user, None, bypass_filter)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -1128,7 +1139,7 @@ async def generate_chat_completion(
return await send_request(
f'{url}/api/chat',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=form_data.stream,
content_type='application/x-ndjson',
@ -1170,6 +1181,14 @@ class OpenAICompletionForm(BaseModel):
model_config = ConfigDict(extra='allow')
class OpenAIEmbeddingsForm(BaseModel):
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
model: str
input: object
model_config = ConfigDict(extra='allow')
@router.post('/v1/completions')
@router.post('/v1/completions/{url_idx}')
async def generate_openai_completion(
@ -1207,7 +1226,8 @@ async def generate_openai_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -1216,7 +1236,7 @@ async def generate_openai_completion(
return await send_request(
f'{url}/v1/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
@ -1225,6 +1245,56 @@ async def generate_openai_completion(
)
@router.post('/v1/embeddings')
@router.post('/v1/embeddings/{url_idx}')
async def generate_openai_embeddings(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward an embeddings request via the OpenAI-compatible proxy."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
metadata = form_data.pop('metadata', None)
try:
form_data = OpenAIEmbeddingsForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True)}
payload.pop('metadata', None)
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/embeddings',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
metadata=metadata,
api_config=api_config,
request=request,
)
@router.post('/v1/chat/completions')
@router.post('/v1/chat/completions/{url_idx}')
async def generate_openai_chat_completion(
@ -1266,7 +1336,8 @@ async def generate_openai_chat_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@ -1275,7 +1346,7 @@ async def generate_openai_chat_completion(
return await send_request(
f'{url}/v1/chat/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
@ -1317,10 +1388,8 @@ async def generate_anthropic_messages(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
api_configs = await Config.get('ollama.api_configs', {})
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
@ -1329,7 +1398,7 @@ async def generate_anthropic_messages(
return await send_request(
f'{url}/v1/messages',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,
@ -1377,10 +1446,8 @@ async def generate_responses(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
api_configs = await Config.get('ollama.api_configs', {})
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
@ -1389,7 +1456,7 @@ async def generate_responses(
return await send_request(
f'{url}/v1/responses',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,

View file

@ -301,6 +301,91 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
return url, key, api_config
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
requested_model = form_data.get('model')
if not requested_model:
raise HTTPException(status_code=400, detail='model is required')
payload = {**form_data}
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
payload['model'] = model_id
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if not model or 'urlIdx' not in model:
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND())
url, key, api_config = await get_openai_connection(model['urlIdx'])
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
return requested_model, payload, url, key, headers, cookies
async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int:
"""Forward an Anthropic token-count request through an OpenAI-compatible connection."""
requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target(
request, form_data, user
)
request_url = f'{url.rstrip("/")}/messages/count_tokens'
response = None
try:
session = await get_session()
response = await session.request(
method='POST',
url=request_url,
data=json.dumps(payload),
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
if response.status >= 400:
await publish_model_provider_request_failed(
request,
actor=user,
provider='openai-compatible',
base_url=url,
api_key=key,
status=response.status,
requested_model=requested_model,
upstream_error=response_data,
)
raise HTTPException(status_code=response.status, detail=response_data)
input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider')
return input_tokens
except HTTPException:
raise
except Exception:
log.exception('Failed to count Anthropic tokens for model %s', requested_model)
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
finally:
await cleanup_response(response)
@router.get('/config')
async def get_config(request: Request, user=Depends(get_admin_user)):
return await get_openai_config()
@ -333,6 +418,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
'openai.api_configs': api_configs,
}
)
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OPENAI_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
await publish_event(
request,
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
@ -551,6 +646,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config()
if not enable_openai_api:
request.app.state.OPENAI_MODELS = {}
return {'data': []}
responses = await get_all_models_responses(request, user=user)
@ -1107,6 +1203,9 @@ async def generate_chat_completion(
form_data: dict,
user=Depends(get_verified_user),
):
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),

View file

@ -72,6 +72,8 @@ async def get_skills(
async def get_skill_list(
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@ -86,6 +88,10 @@ async def get_skill_list(
filter['query'] = query
if view_option:
filter['view_option'] = view_option
if order_by:
filter['order_by'] = order_by
if direction:
filter['direction'] = direction
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
groups = await Groups.get_groups_by_member_id(user.id, db=db)

View file

@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict:
##################################
class ActiveChatsForm(BaseModel):
chat_ids: list[str]
@router.post('/active/chats')
async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)):
"""Check which chat IDs have active tasks."""
from open_webui.tasks import get_active_chat_ids
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
return {'active_chat_ids': active}
@router.get('/config')
async def get_task_config(request: Request, user=Depends(get_verified_user)):
return await get_config_values(TASK_CONFIG_KEYS)

View file

@ -20,6 +20,7 @@ from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.auth import get_verified_user
from open_webui.utils.terminals import get_terminal_server_url
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
from starlette.background import BackgroundTask
@ -100,7 +101,7 @@ async def proxy_terminal(
if not await has_connection_access(user, connection, user_group_ids):
return JSONResponse({'error': 'Access denied'}, status_code=403)
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503)
@ -110,11 +111,6 @@ async def proxy_terminal(
target_url = f'{base_url}/{safe_path}'
# Route through orchestrator policy endpoint if policy_id is set
policy_id = connection.get('policy_id')
if policy_id:
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
if request.query_params:
target_url += f'?{request.query_params}'
@ -272,7 +268,7 @@ async def ws_terminal(
return
user, connection = result
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
await ws.close(code=4003, reason='Terminal server URL not configured')
return
@ -280,8 +276,6 @@ async def ws_terminal(
# Build upstream WebSocket URL (no token in URL)
ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://')
# Route through orchestrator policy endpoint if policy_id is set
policy_id = connection.get('policy_id')
upstream_params = {}
# For orchestrator-backed servers, pass user_id
upstream_params['user_id'] = user.id
@ -292,10 +286,7 @@ async def ws_terminal(
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
safe_session_id = urllib.parse.quote(session_id, safe='')
if policy_id:
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
else:
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
if upstream_params:
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'

View file

@ -10,7 +10,7 @@ import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
@ -72,20 +72,23 @@ async def get_tools(
tools = []
# Local Tools
tools_cache = get_tools_cache(request)
for tool in await Tools.get_tools(defer_content=True, db=db):
tool_module = tools_cache.get(tool.id)
has_user_valves = (
hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False)
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
'has_user_valves': has_user_valves,
}
if ENABLE_PLUGINS:
tools_cache = get_tools_cache(request)
for tool in await Tools.get_tools(defer_content=True, db=db):
tool_module = tools_cache.get(tool.id)
has_user_valves = (
hasattr(tool_module, 'UserValves')
if tool_module
else (tool.meta.has_user_valves if tool.meta else False)
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
'has_user_valves': has_user_valves,
}
)
)
)
# OpenAPI Tool Servers
server_access_grants = {}
@ -199,6 +202,9 @@ async def get_tools(
@router.get('/list', response_model=list[ToolAccessResponse])
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
tools = await Tools.get_tools(defer_content=True, db=db)
else:

View file

@ -4,9 +4,11 @@ import base64
import io
import logging
import time
from collections import Counter
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import (
@ -196,6 +200,7 @@ class SharingPermissions(BaseModel):
public_skills: bool = False
notes: bool = False
public_notes: bool = True
folders: bool = False
public_chats: bool = False
public_calendars: bool = False
@ -259,6 +264,129 @@ class UserPermissions(BaseModel):
settings: SettingsPermissions
class UserUsageTotals(BaseModel):
lifetime_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
peak_daily_tokens: int = 0
longest_chat_seconds: int = 0
current_streak: int = 0
longest_streak: int = 0
total_chats: int = 0
active_days: int = 0
models_used: int = 0
messages: int = 0
user_messages: int = 0
assistant_messages: int = 0
class UserUsageHeatmapEntry(BaseModel):
date: str
messages: int = 0
chats: int = 0
tokens: int = 0
models: dict[str, int] = Field(default_factory=dict)
class UserUsageModelEntry(BaseModel):
model_id: str
messages: int = 0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class UserUsageToolEntry(BaseModel):
name: str
count: int
class UserUsageInsights(BaseModel):
most_used_model: Optional[str] = None
average_tokens_per_chat: float = 0
average_messages_per_active_day: float = 0
user_message_share: float = 0
assistant_message_share: float = 0
class UserUsagePeriod(BaseModel):
start_date: int
end_date: int
days: int
class UserUsageResponse(BaseModel):
totals: UserUsageTotals
heatmap: list[UserUsageHeatmapEntry]
weekly_heatmap: list[UserUsageHeatmapEntry]
cumulative_heatmap: list[UserUsageHeatmapEntry]
insights: UserUsageInsights
top_models: list[UserUsageModelEntry]
top_tools: list[UserUsageToolEntry] = []
period: UserUsagePeriod
def _week_start(date: datetime) -> datetime:
return date - timedelta(days=date.weekday())
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
weeks: dict[str, dict] = {}
for day in heatmap:
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
entry['messages'] += day.get('messages', 0)
entry['chats'] += day.get('chats', 0)
entry['tokens'] += day.get('tokens', 0)
entry['models'].update(day.get('models', {}))
return [
{
**weeks[key],
'models': dict(weeks[key]['models']),
}
for key in sorted(weeks)
]
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
models: Counter[str] = Counter()
cumulative = []
for day in heatmap:
totals['messages'] += day.get('messages', 0)
totals['chats'] += day.get('chats', 0)
totals['tokens'] += day.get('tokens', 0)
models.update(day.get('models', {}))
cumulative.append(
{
'date': day['date'],
**totals,
'models': dict(models),
}
)
return cumulative
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
longest = 0
current_run = 0
for day in heatmap:
if day.get('messages', 0) > 0:
current_run += 1
longest = max(longest, current_run)
else:
current_run = 0
current = 0
for day in reversed(heatmap):
if day.get('messages', 0) <= 0:
break
current += 1
return {'current': current, 'longest': longest}
@router.get('/default/permissions', response_model=UserPermissions)
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
user_permissions = await Config.get('user.permissions')
@ -348,6 +476,23 @@ async def update_user_settings_by_session_user(
# If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings['ui'].pop('toolServers', None)
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
if (
user.role != 'admin'
and (
'notifications' in updated_user_settings
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
)
and not await has_permission(
user.id,
'features.webhooks',
await Config.get('user.permissions'),
)
):
updated_user_settings.pop('notifications', None)
if isinstance(ui_notifications, dict):
ui_notifications.pop('webhook_url', None)
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user:
await publish_event(
@ -455,6 +600,86 @@ async def update_user_info_by_session_user( # PATCH-style merge
return updated.info
############################
# GetUserUsageBySessionUser
############################
@router.get('/usage', response_model=UserUsageResponse)
async def get_user_usage_by_session_user(
days: Optional[int] = Query(None, ge=7, le=732),
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
now = int(time.time())
period_end = end_date or now
if start_date is not None:
period_start = start_date
elif days is not None:
period_start = period_end - ((days - 1) * 86400)
else:
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
if period_start > period_end:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='start_date must be before end_date',
)
period_days = max(1, int((period_end - period_start) / 86400) + 1)
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
period_summary = await ChatMessages.get_user_usage_summary(
user.id, period_start, period_end, timezone=user.timezone, db=db
)
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
streaks = _calculate_streaks(heatmap)
total_messages = period_summary.get('messages', 0)
total_chats = chat_stats.get('total_chats', 0)
active_days = period_summary.get('active_days', 0)
assistant_messages = period_summary.get('assistant_messages', 0)
user_messages = period_summary.get('user_messages', 0)
return UserUsageResponse(
totals=UserUsageTotals(
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
input_tokens=lifetime_summary.get('input_tokens', 0),
output_tokens=lifetime_summary.get('output_tokens', 0),
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
current_streak=streaks['current'],
longest_streak=streaks['longest'],
total_chats=total_chats,
active_days=active_days,
models_used=lifetime_summary.get('models_used', 0),
messages=total_messages,
user_messages=user_messages,
assistant_messages=assistant_messages,
),
heatmap=heatmap,
weekly_heatmap=_build_weekly_heatmap(heatmap),
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
insights=UserUsageInsights(
most_used_model=top_models[0]['model_id'] if top_models else None,
average_tokens_per_chat=(
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
),
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
),
top_models=top_models,
top_tools=top_tools,
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
)
############################
# GetUserById
############################

View file

@ -57,6 +57,29 @@ REDIS = None
# Configure CORS for Socket.IO
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
Every instance subscribed to the shared Socket.IO channel receives every
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
the full packet before discovering the target room has no participants on
this instance, so each instance burns CPU serializing payloads addressed
to sessions it does not host — a cost that grows with instance count.
Bail out before that work when the room is empty here. Only string
rooms take the fast path; broadcasts (``room=None``) and any other
room shape (e.g. lists, which upstream indexes before validating)
always pass through unchanged.
"""
async def _handle_emit(self, message):
room = message.get('room')
if isinstance(room, str):
namespace = message.get('namespace') or '/'
if next(self.get_participants(namespace, room), None) is None:
return
await super()._handle_emit(message)
if WEBSOCKET_MANAGER == 'redis':
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
ws_redis_url = (
@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis':
if sentinel_hosts
else WEBSOCKET_REDIS_URL
)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
@ -529,6 +552,12 @@ async def chat_events(sid, data):
if event_type == 'last_read_at':
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(data['chat_id'], 'chat.read')
except Exception:
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
def normalize_document_id(document_id: str) -> str:
@ -925,12 +954,17 @@ async def get_event_emitter(request_info, update_db=True):
user_id = request_info['user_id']
chat_id = request_info['chat_id']
message_id = request_info['message_id']
internal = request_info.get('internal') is True
if internal and event_data.get('type') == 'notification':
return
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
**({'internal': True} if internal else {}),
'data': event_data,
},
room=f'user:{user_id}',
@ -992,6 +1026,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'embeds': embeds,
},
touch=False,
)
elif event_type == 'files':
@ -1009,6 +1044,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'files': files,
},
touch=False,
)
elif event_type in ('source', 'citation'):
@ -1028,6 +1064,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'sources': sources,
},
touch=False,
)
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -199,12 +199,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool:
"""Check if a chat has any active tasks."""
task_ids = await list_task_ids_by_item_id(redis, chat_id)
return len(task_ids) > 0
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
"""Filter a list of chat_ids to only those with active tasks."""
active = []
for chat_id in chat_ids:
if await has_active_tasks(redis, chat_id):
active.append(chat_id)
return active

View file

@ -12,7 +12,7 @@ import asyncio
import json
import logging
import time
from typing import Optional
from typing import Literal, Optional
from fastapi import Request
@ -49,6 +49,10 @@ from open_webui.routers.memories import (
add_memory as _add_memory,
)
from open_webui.routers.retrieval import search_web as _search_web
from open_webui.tasks import stop_item_tasks
from open_webui.events import EVENTS, publish_event
from open_webui.socket.main import sio
from open_webui.utils.notifications import notify_target
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@ -56,6 +60,33 @@ log = logging.getLogger(__name__)
MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
async def _has_write_access_to_note(note, user_id: str) -> bool:
if note.user_id == user_id:
return True
from open_webui.models.access_grants import AccessGrants
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
return await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='write',
user_group_ids=set(user_group_ids),
)
async def _emit_note_updated(request: Request, user: dict, note) -> None:
await sio.emit('events:note', note.model_dump(), to=f'note:{note.id}')
await publish_event(
request,
EVENTS.NOTE_UPDATED,
actor=user,
subject_id=note.id,
data={'title': note.title},
)
async def _has_read_access_to_file(
file,
user_id: str,
@ -81,6 +112,33 @@ async def _has_read_access_to_file(
# =============================================================================
async def notify(
message: str,
target: str = '',
title: str = '',
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Send a notification to the user's configured notification target.
:param message: Notification body.
:param target: Optional target id or name. Empty uses the default target.
:param title: Optional notification title.
"""
user_id = (__user__ or {}).get('id')
if not user_id:
return 'Notification failed: user not found.'
app_name = getattr(getattr(__request__, 'app', None), 'state', None)
app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI')
try:
result = await notify_target(user_id, message, target=target, title=title, app_name=app_name)
return f'Notification sent to {result.get("target_id")}.'
except Exception as e:
return f'Notification failed: {e}'
async def get_current_timestamp(
__request__: Request = None,
__user__: dict = None,
@ -1048,12 +1106,16 @@ async def view_note(
from open_webui.models.access_grants import AccessGrants
if note.user_id != user_id and not await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='read',
user_group_ids=set(user_group_ids),
if (
__user__.get('role') != 'admin'
and note.user_id != user_id
and not await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='read',
user_group_ids=set(user_group_ids),
)
):
return json.dumps({'error': 'Access denied'})
@ -1128,16 +1190,20 @@ async def write_note(
async def replace_note_content(
note_id: str,
content: str,
content: Optional[str] = None,
operations: Optional[list[dict]] = None,
title: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Update the markdown content, and optionally the title, of an existing note.
Update an existing note by replacing the whole markdown content or applying range operations.
:param note_id: The ID of the note to update
:param content: The new markdown content for the note
:param content: The new markdown content for a whole-note update
:param operations: Optional note operations:
- {"action": "replace", "content": "..."}
- {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."}
:param title: Optional new title for the note
:return: JSON with success status and updated note info
"""
@ -1153,25 +1219,113 @@ async def replace_note_content(
note = await Notes.get_note_by_id(note_id)
if not note:
return json.dumps({'error': 'Note not found'})
return json.dumps({'error': 'Note not found', 'code': 'not_found'})
# Check write permission
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id):
return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'})
from open_webui.models.access_grants import AccessGrants
current_content = ((note.data or {}).get('content') or {}).get('md') or ''
applied_operation_count = 0
if operations is not None:
if not isinstance(operations, list) or len(operations) == 0:
return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'})
if note.user_id != user_id and not await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Write access denied'})
range_operations = []
for idx, operation in enumerate(operations):
if not isinstance(operation, dict):
return json.dumps(
{'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx}
)
# Build update form
update_data = {'data': {'content': {'md': content}}}
action = operation.get('action')
replacement = operation.get('content')
if action == 'replace':
if len(operations) != 1:
return json.dumps(
{
'error': 'replace operation must be the only operation',
'code': 'invalid_operations',
'index': idx,
}
)
if not isinstance(replacement, str):
return json.dumps(
{
'error': 'replace operation content must be a string',
'code': 'invalid_content',
'index': idx,
}
)
content = replacement
applied_operation_count = 1
break
if action != 'replace_range':
return json.dumps(
{'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action}
)
start = operation.get('start')
end = operation.get('end')
expected = operation.get('expected')
if not isinstance(start, int) or not isinstance(end, int):
return json.dumps(
{'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx}
)
if not isinstance(replacement, str):
return json.dumps(
{'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx}
)
if start < 0 or end < start or end > len(current_content):
return json.dumps(
{'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx}
)
if expected is not None and current_content[start:end] != expected:
return json.dumps(
{
'error': 'operation expected text does not match current content',
'code': 'expected_mismatch',
'index': idx,
}
)
range_operations.append({'start': start, 'end': end, 'content': replacement})
range_operations.sort(key=lambda operation: operation['start'])
previous_end = 0
for idx, operation in enumerate(range_operations):
if operation['start'] < previous_end:
return json.dumps(
{'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx}
)
previous_end = operation['end']
if range_operations:
content = current_content
for operation in reversed(range_operations):
content = content[: operation['start']] + operation['content'] + content[operation['end'] :]
applied_operation_count = len(range_operations)
elif content is None:
return json.dumps({'error': 'content or operations is required', 'code': 'content_required'})
try:
await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}')
except Exception:
pass
update_data = {
'data': {
**(note.data or {}),
'content': {
**((note.data or {}).get('content') or {}),
'json': None,
'html': '',
'md': content,
},
}
}
if title:
update_data['title'] = title
@ -1179,7 +1333,9 @@ async def replace_note_content(
updated_note = await Notes.update_note_by_id(note_id, form)
if not updated_note:
return json.dumps({'error': 'Failed to update note'})
return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'})
await _emit_note_updated(__request__, __user__, updated_note)
return json.dumps(
{
@ -1187,12 +1343,13 @@ async def replace_note_content(
'id': updated_note.id,
'title': updated_note.title,
'updated_at': updated_note.updated_at,
'applied_operation_count': applied_operation_count,
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'replace_note_content error: {e}')
return json.dumps({'error': str(e)})
return json.dumps({'error': str(e), 'code': 'unexpected_error'})
# =============================================================================
@ -1348,6 +1505,85 @@ async def view_chat(
return json.dumps({'error': str(e)})
# =============================================================================
# SUB-AGENT TOOL
# =============================================================================
async def delegate_task(
task: str,
context: str = '',
background: bool = False,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
__chat_id__: str = None,
__message_id__: str = None,
) -> str:
"""
Delegate focused work to a parallel sub-agent using the current model and tools.
:param task: The specific task for the sub-agent to complete
:param context: Relevant context, decisions, or file paths for the task
:param background: Return immediately and continue this chat when the sub-agent finishes
:return: Foreground result text, or a JSON dispatch handle for background work
"""
if __request__ is None:
return 'Error: request context not available.'
if getattr(__request__.state, 'internal', False) is True:
return 'Error: sub-agents cannot delegate recursively.'
from open_webui.utils.subagents import delegate
return await delegate(
task,
context,
background,
request=__request__,
user_data=__user__ or {},
metadata=__metadata__ or {},
parent_chat_id=__chat_id__ or '',
parent_message_id=__message_id__,
)
async def timer(
prompt: str,
at: str,
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None = None,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
__chat_id__: str = None,
__message_id__: str = None,
) -> str:
"""
Set a one-shot timer for this chat.
:param prompt: The prompt to send back into this chat when the timer fires
:param at: Relative time like 10s, 5m, 1h, 2d, or a timezone-aware RFC 3339 timestamp
:param cancel_on: Optional events that cancel the timer before it fires
:return: JSON status with the scheduled time, or an error string
"""
if __request__ is None:
return 'Error: request context not available.'
if getattr(__request__.state, 'internal', False) is True:
return 'Error: timers cannot be set from internal chats.'
from open_webui.utils.timers import create_timer
return await create_timer(
prompt=prompt,
at=at,
cancel_on=cancel_on,
request=__request__,
user_data=__user__ or {},
metadata=__metadata__ or {},
parent_chat_id=__chat_id__ or '',
parent_message_id=__message_id__,
)
# =============================================================================
# CHANNELS TOOLS
# =============================================================================

View file

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

View file

@ -253,6 +253,22 @@ async def filter_allowed_access_grants(
):
access_grants = strip_user_access_grants(access_grants)
if any(
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
for grant in access_grants
) and not await has_permission(
user_id,
'access_grants.allow_groups',
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= 'group'
]
return access_grants

View file

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

View file

@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
'model',
'messages',
'system',
'max_tokens',
'temperature',
'top_p',
'top_k',
'stop_sequences',
'stream',
'metadata',
'service_tier',
'tools',
'tool_choice',
'reasoning_effort',
}
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
@ -109,7 +126,9 @@ def _finalize_openai_content(blocks: list) -> str | list:
return blocks
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
def convert_anthropic_to_openai_payload(
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
@ -350,6 +369,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if 'max_tokens' in anthropic_payload:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
captured_passthrough_params = {
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
}
if isinstance(passthrough_params, str):
passthrough_params = passthrough_params.split(',')
elif not isinstance(passthrough_params, (list, tuple, set)):
passthrough_params = []
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
if '*' in passthrough_param_names:
openai_payload.update(captured_passthrough_params)
else:
for param in passthrough_param_names:
if param in captured_passthrough_params:
openai_payload[param] = captured_passthrough_params[param]
output_config = anthropic_payload.get('output_config')
if isinstance(output_config, dict):
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
openai_payload['reasoning_effort'] = output_config['effort']
format_config = output_config.get('format')
if isinstance(format_config, dict):
format_type = format_config.get('type')
if format_type == 'json_schema':
json_schema = {
'name': format_config.get('name', 'response_schema'),
'schema': format_config.get('schema', {}),
}
if 'description' in format_config:
json_schema['description'] = format_config['description']
if 'strict' in format_config:
json_schema['strict'] = format_config['strict']
openai_payload['response_format'] = {
'type': 'json_schema',
'json_schema': json_schema,
}
elif format_type == 'json_object':
openai_payload['response_format'] = {'type': format_type}
if 'reasoning_effort' in anthropic_payload:
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
# Common parameters
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
if param in anthropic_payload:
@ -395,7 +456,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
return openai_payload
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
def convert_openai_to_anthropic_response(
openai_response: dict, model: str = '', input_tokens: int | None = None
) -> dict:
"""
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
"""
@ -419,6 +482,32 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
# Build content blocks
content = []
message_thinking = message.get('thinking')
thinking_blocks = message.get('thinking_blocks') or []
if not thinking_blocks and isinstance(message_thinking, dict):
thinking_blocks = message_thinking.get('blocks') or []
has_thinking = False
for block in thinking_blocks:
if not isinstance(block, dict):
continue
thinking = block.get('thinking') or block.get('content') or block.get('text')
if not thinking:
continue
thinking_block = {'type': 'thinking', 'thinking': thinking}
if block.get('signature'):
thinking_block['signature'] = block['signature']
content.append(thinking_block)
has_thinking = True
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
if not reasoning_content and isinstance(message_thinking, str):
reasoning_content = message_thinking
if reasoning_content and not has_thinking:
content.append({'type': 'thinking', 'thinking': reasoning_content})
message_content = message.get('content')
if message_content:
content.append({'type': 'text', 'text': message_content})
@ -443,8 +532,12 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
# Usage
openai_usage = openai_response.get('usage', {})
usage = {
'input_tokens': openai_usage.get('prompt_tokens', 0),
'output_tokens': openai_usage.get('completion_tokens', 0),
'input_tokens': (
input_tokens
if input_tokens is not None
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0))
),
'output_tokens': openai_usage.get('output_tokens', openai_usage.get('completion_tokens', 0)),
}
if 'cache_creation_input_tokens' in openai_usage:
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
@ -463,7 +556,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int = 0):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
@ -480,13 +573,13 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
import uuid as _uuid
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
input_tokens = 0
output_tokens = 0
stop_reason = 'end_turn'
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
thinking_block_open = False
text_block_open = False
# Accumulated state for each tool call, keyed by tool call id.
@ -510,7 +603,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
'model': model,
'stop_reason': None,
'stop_sequence': None,
'usage': {'input_tokens': 0, 'output_tokens': 0},
'usage': {'input_tokens': input_tokens, 'output_tokens': 0},
},
}
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
@ -541,8 +634,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if not choices:
# Check for usage in the final chunk
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
input_tokens = data['usage'].get(
'input_tokens', data['usage'].get('prompt_tokens', input_tokens)
)
output_tokens = data['usage'].get(
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
)
continue
delta = choices[0].get('delta', {})
@ -551,14 +648,57 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
# Update usage if present
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
input_tokens = data['usage'].get('input_tokens', data['usage'].get('prompt_tokens', input_tokens))
output_tokens = data['usage'].get(
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
)
reasoning_content = (
delta.get('reasoning_content')
or delta.get('reasoning')
or delta.get('thinking')
or message.get('reasoning_content')
or message.get('reasoning')
)
if not reasoning_content:
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
for block in thinking_blocks:
if isinstance(block, dict):
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
if reasoning_content:
break
if reasoning_content and not text_block_open and not has_tool_calls:
if not thinking_block_open:
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {'type': 'thinking', 'thinking': ''},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
thinking_block_open = True
block_delta = {
'type': 'content_block_delta',
'index': current_block_index,
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle text content ---
# Anthropic expects text blocks before tool blocks, so skip
# text deltas once any tool call has started.
content = delta.get('content')
if content and not has_tool_calls:
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if not text_block_open:
block_start = {
'type': 'content_block_start',
@ -584,6 +724,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if tool_calls:
# Close text block if one is open (text comes before tools)
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if text_block_open:
block_stop = {
'type': 'content_block_stop',
@ -695,6 +844,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except Exception as e:
log.error(f'Error in Anthropic stream conversion: {e}')
# Close any open thinking block
if thinking_block_open:
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
current_block_index += 1
# Flush any tools that buffered arguments but never emitted a block
for tool in tracked_tool_calls.values():
if not tool['started'] and tool['name']:

View file

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

View file

@ -96,11 +96,15 @@ def get_license_data(app, key):
setattr(app.state, 'LICENSE_METADATA', v)
def handler(u):
res = requests.post(
f'{u}/api/v1/license/',
json={'key': key, 'version': '1'},
timeout=5,
)
try:
res = requests.post(
f'{u}/api/v1/license/',
json={'key': key, 'version': '1'},
timeout=5,
)
except Exception as ex:
log.error(f'License: retrieval issue from {u}: {ex}')
return False
if getattr(res, 'ok', False):
payload = getattr(res, 'json', lambda: {})()
@ -399,8 +403,6 @@ async def get_current_user(
# Refresh the user's last active timestamp
# Fire-and-forget via asyncio.create_task to avoid blocking
import asyncio
asyncio.create_task(Users.update_last_active_by_id(user.id))
return user
else:
@ -433,24 +435,30 @@ async def get_current_user_by_api_key(request, api_key: str):
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
user_permissions = await Config.get('user.permissions')
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
config_values = await Config.get_many(
'auth.enable_api_keys',
'user.permissions',
'auth.api_key.endpoint_restrictions',
'auth.api_key.allowed_endpoints',
)
if not request.state.enable_api_keys or (
user.role != 'admin'
and not await has_permission(
if not config_values.get('auth.enable_api_keys'):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
if user.role != 'admin':
user_permissions = config_values.get('user.permissions')
if not await has_permission(
user.id,
'features.api_keys',
user_permissions,
)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
# Enforce endpoint restrictions — checked here (not in middleware)
# so it applies regardless of how the API key was transported
# (Authorization header, cookie, x-api-key header, etc.).
if enable_endpoint_restrictions:
if config_values.get('auth.api_key.endpoint_restrictions'):
allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '')
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)

View file

@ -7,9 +7,11 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
The scheduler_worker_loop handles all time-based background work:
- Automation execution (claim_due → execute)
- Calendar event alerts (upcoming events → socket + webhook notifications)
- One-shot chat timers
Environment:
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
TIMER_POLL_INTERVAL – seconds between timer polls (default: 1)
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
"""
@ -36,11 +38,13 @@ from open_webui.models.users import Users
from open_webui.utils.auth import create_token
from open_webui.utils.misc import parse_duration
from open_webui.utils.task import prompt_template
from open_webui.utils.terminals import get_terminal_server_url
from starlette.datastructures import Headers
log = logging.getLogger(__name__)
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1'))
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
@ -65,7 +69,7 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
return None
def _parse_rule(s: str):
def _parse_rule(s: str, now: Optional[datetime] = None):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
@ -77,6 +81,20 @@ def _parse_rule(s: str):
if freq in ('MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
if (
now is not None
and s.startswith('RRULE:')
and '\n' not in s
and '\r' not in s
and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'}
):
try:
interval = int(parts.get('INTERVAL', '1'))
if interval > 0:
step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval)
return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True)
except (TypeError, ValueError):
pass
return rrulestr(s, dtstart=epoch, ignoretz=True)
return rrulestr(s, ignoretz=True)
@ -88,12 +106,12 @@ def validate_rrule(s: str, tz: str = None) -> None:
clock so that near-future schedules are not incorrectly rejected
on servers whose system clock is ahead (e.g. UTC vs US timezones).
"""
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
zi = _resolve_tz(tz)
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
try:
rule = _parse_rule(s, now)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if rule.after(now) is None:
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
@ -102,7 +120,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
zi = _resolve_tz(tz)
now = datetime.now(zi) if zi else datetime.now()
dt = _parse_rule(s).after(now.replace(tzinfo=None))
now_naive = now.replace(tzinfo=None)
dt = _parse_rule(s, now_naive).after(now_naive)
if dt is None:
return None
if zi:
@ -117,9 +136,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
preview matches the user's local clock (same as next_run_ns).
"""
zi = _resolve_tz(tz)
rule = _parse_rule(s)
result = []
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
rule = _parse_rule(s, now)
dt = now
for _ in range(n):
dt = rule.after(dt)
@ -141,8 +160,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
"""
if 'COUNT=1' in s:
return None
rule = _parse_rule(s)
now = datetime.now()
rule = _parse_rule(s, now)
first = rule.after(now)
if first is None:
return None
@ -173,9 +192,30 @@ async def scheduler_worker_loop(app) -> None:
Runs on every instance. Poll interval is configurable via
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
log.info(
f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, '
f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)'
)
next_scheduler_poll = 0.0
while True:
try:
now = time.monotonic()
# ── Timers ──
try:
from open_webui.utils.timers import claim_due_timers, execute_due_timer
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
except Exception:
log.exception('Scheduler: timer error')
if now < next_scheduler_poll:
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
continue
# Jitter to spread automation/calendar load across instances; timers keep a tight poll.
next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)
# ── Automations ──
if await Config.get('automations.enable'):
try:
@ -198,8 +238,7 @@ async def scheduler_worker_loop(app) -> None:
except Exception:
log.exception('Scheduler worker error')
# Jitter to spread load across instances
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
##########################
@ -323,16 +362,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
log.warning(f'Terminal server {server_id} not found for CWD set')
return
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
return
# Build target URL — route through orchestrator policy if configured
policy_id = connection.get('policy_id')
if connection.get('server_type') == 'orchestrator' and policy_id:
target_url = f'{base_url}/p/{policy_id}/files/cwd'
else:
target_url = f'{base_url}/files/cwd'
target_url = f'{base_url}/files/cwd'
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
if chat_id:
@ -613,40 +647,25 @@ async def _check_calendar_alerts(app) -> None:
except Exception:
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
# Send webhook notification if user has one configured
# Send target notification if user has one configured
try:
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
if enable_user_webhooks:
user = await Users.get_user_by_id(event.user_id)
if user and user.settings:
webhook_url = (
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
if isinstance(user.settings, dict)
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
'notifications', {}
).get('webhook_url', None)
if hasattr(user.settings, 'ui')
else None
)
if webhook_url:
from open_webui.utils.webhook import post_webhook
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await post_webhook(
webui_name,
webhook_url,
f'{event.title} — starting {time_str}',
{
'action': 'calendar_alert',
'title': event.title,
'minutes_until': minutes_until,
'event_id': event.id,
},
)
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await publish_event(
app,
EVENTS.CALENDAR_ALERT,
subject_id=event.id,
subject_type='calendar.event',
source='scheduler',
data={
**alert_data,
'user_id': event.user_id,
'starts_in': time_str,
'message': f'{event.title}: starting {time_str}',
},
message=event.title,
)
except Exception:
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True)
async def _record_run(

View file

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

View file

@ -5,7 +5,6 @@ import logging
from typing import Any
from fastapi.responses import JSONResponse
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list
@ -53,7 +52,7 @@ async def compact_messages_for_request(
return messages, None, False
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
token_threshold = _resolve_token_threshold(config['token_threshold'], metadata)
token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata)
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
return messages, previous_summary, False
@ -108,12 +107,15 @@ async def compact_messages_for_request(
raise
chat_id = metadata.get('chat_id')
checkpoint_message_id = metadata.get('user_message_id') or metadata.get('message_id')
checkpoint_message_id = (
recent_messages[0].get('id') or metadata.get('user_message_id') or metadata.get('message_id')
)
if chat_id and checkpoint_message_id and not chat_id.startswith(('local:', 'channel:')):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
checkpoint_message_id,
{'contextSummary': summary},
touch=False,
)
log.info(
@ -146,8 +148,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
if not config['enable']:
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
history = (chat.chat or {}).get('history') or {}
current_id = history.get('currentId')
chat_data = chat.chat or {}
history = chat_data.get('history') or {}
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
if not current_id:
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
current_id = chat_data['messages'][-1].get('id')
if not current_id:
return {'ok': True, 'compacted': False, 'reason': 'empty'}
@ -156,11 +163,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
messages_map = history.get('messages') or {}
messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id))
if len(messages) <= 2:
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
compacted_messages = messages[:-1]
recent_messages = messages[-1:]
if not compacted_messages or not recent_messages:
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
summary = await _generate_summary(
request,
user,
@ -171,7 +178,9 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
previous_summary,
config['prompt_template'],
)
await Chats.upsert_message_to_chat_by_id_and_message_id(chat.id, current_id, {'contextSummary': summary})
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat.id, current_id, {'contextSummary': summary}, touch=False
)
return {
'ok': True,
@ -186,11 +195,14 @@ async def _load_config() -> dict:
values = await Config.get_many(
'chat.context_compaction.enable',
'chat.context_compaction.token_threshold',
'chat.context_compaction.token_cap',
'chat.context_compaction.prompt_template',
)
token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000
return {
'enable': bool(values.get('chat.context_compaction.enable', False)),
'token_threshold': int(values.get('chat.context_compaction.token_threshold', 80000) or 80000),
'token_threshold': token_threshold,
'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold,
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
}
@ -203,11 +215,57 @@ def _parse_positive_int(value: Any) -> int | None:
return parsed if parsed > 0 else None
def _resolve_token_threshold(global_threshold: int, metadata: dict) -> int:
def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int:
configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold'))
if configured_threshold is None:
return global_threshold
return min(configured_threshold, global_threshold)
return min(configured_threshold or global_threshold, global_cap)
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
chat_data = chat.chat or {}
history = chat_data.get('history') or {}
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
if not current_id:
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
current_id = chat_data['messages'][-1].get('id')
if not current_id:
return None
messages_map = await Chats.get_messages_map_by_chat_id(chat.id)
messages = get_message_list(messages_map or history.get('messages') or {}, current_id)
if not messages:
return None
config = await _load_config()
if not config['enable']:
return None
params = ((chat.chat or {}).get('params') or {}).copy()
if model_id:
params['model'] = model_id
threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params})
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
for idx in range(len(messages) - 1, -1, -1):
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
input_tokens = (usage or {}).get('input_tokens') or (usage or {}).get('prompt_tokens')
if isinstance(usage, dict) and input_tokens:
tokens = int(input_tokens or 0) + int(usage.get('output_tokens') or usage.get('completion_tokens') or 0)
tokens += _estimate_messages_tokens(messages[idx + 1 :])
return _build_context_usage(tokens, threshold)
tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages)
return _build_context_usage(tokens, threshold)
def _build_context_usage(tokens: int, threshold: int) -> dict:
return {
'tokens': tokens,
'estimated_tokens': tokens,
'threshold': threshold,
'percent': round((tokens / threshold) * 100) if threshold > 0 else 0,
'source': 'estimated',
}
def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]:
@ -246,17 +304,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
def _find_compaction_boundary(messages: list[dict]) -> int:
keep_count = max(2, len(messages) * 2 // 5)
split = max(1, len(messages) - keep_count)
while split < len(messages) - 1:
previous = messages[split - 1] if split > 0 else {}
current = messages[split]
if current.get('role') == 'tool' or previous.get('tool_calls') or previous.get('output'):
split += 1
continue
break
return min(split, len(messages) - 2)
target = max(1, len(messages) - keep_count)
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
return next((idx for idx in reversed(boundaries) if idx <= target), 0)
async def _generate_summary(

View file

@ -1,11 +1,9 @@
import inspect
import logging
from open_webui.env import ENABLE_PLUGINS
from open_webui.models.functions import Functions
from open_webui.utils.plugin import (
get_function_module_from_cache,
load_function_module_by_id,
)
from open_webui.utils.plugin import get_function_module_from_cache
log = logging.getLogger(__name__)
@ -19,22 +17,15 @@ async def get_function_module(request, function_id, load_from_db=True):
async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
async def get_priority(function_id):
try:
function_module = await get_function_module(request, function_id)
if function_module and hasattr(function_module, 'Valves'):
valves_db = await Functions.get_function_valves_by_id(function_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, 'priority', 0)
except Exception:
pass
return 0
if not ENABLE_PLUGINS:
return []
filter_ids = [function.id for function in await Functions.get_global_filter_functions()]
active_filters = await Functions.get_active_filter_ids()
filter_ids = [fid for fid, is_global in active_filters if is_global]
if 'info' in model and 'meta' in model['info']:
filter_ids.extend(model['info']['meta'].get('filterIds', []))
filter_ids = list(set(filter_ids))
active_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
active_filter_ids = {fid for fid, _ in active_filters}
async def get_active_status(filter_id):
function_module = await get_function_module(request, filter_id)
@ -51,6 +42,18 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active}
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
async def get_priority(function_id):
try:
function_module = await get_function_module(request, function_id)
if function_module and hasattr(function_module, 'Valves'):
valves_db = valves_by_id.get(function_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, 'priority', 0)
except Exception:
pass
return 0
# Pre-compute priorities (async functions can't be used in sort keys)
priorities = {}
@ -64,13 +67,17 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
# Grant these filters the discernment to pass what serves
# and refuse what harms, for every soul in the house.
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
if not ENABLE_PLUGINS:
return form_data, {}
skip_files = None
valves_by_id = None
filter_ids = [function.id for function in filter_functions if function]
for function in filter_functions:
filter = function
filter_id = function.id
if not filter:
if not function:
continue
filter_id = function.id
function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream'))
# Prepare handler function
@ -84,7 +91,9 @@ async def process_filter_functions(request, filter_functions, filter_type, form_
# Apply valves to the function
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
valves = await Functions.get_function_valves_by_id(filter_id)
if valves_by_id is None:
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
valves = valves_by_id.get(filter_id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
try:

View file

@ -33,6 +33,7 @@ from open_webui.env import (
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
ENABLE_API_OUTLET_FILTERS,
ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION,
ENABLE_PLUGINS,
ENABLE_QUERIES_CACHE,
ENABLE_REALTIME_CHAT_SAVE,
ENABLE_RESPONSES_API_STATEFUL,
@ -44,8 +45,10 @@ from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.notes import Notes
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import UserModel, Users
from open_webui.events import EVENTS, publish_event
from open_webui.retrieval.utils import get_sources_from_items
from open_webui.routers.images import (
CreateImageForm,
@ -73,7 +76,8 @@ from open_webui.socket.main import (
get_event_emitter,
)
from open_webui.utils.access_control import has_connection_access, has_permission
from open_webui.utils.access_control.files import get_accessible_folder_files
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.context_compaction import compact_messages_for_request
@ -126,13 +130,54 @@ from open_webui.utils.tools import (
get_tools,
get_updated_tool_function,
)
from open_webui.utils.webhook import post_webhook
from starlette.responses import JSONResponse, Response, StreamingResponse
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
def _last_output_text(output: list | None) -> str:
for item in reversed(output or []):
if item.get('type') != 'message':
continue
parts = item.get('content') or []
text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text')
if text:
return text
return ''
async def publish_chat_finished_event(
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
):
chat_id = metadata.get('chat_id')
if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')):
return
content = content or _last_output_text(output)
webui_url = await Config.get('webui.url')
await publish_event(
request,
EVENTS.CHAT_FINISHED,
actor=user,
subject_id=chat_id,
subject_type='chat',
data={
'user_id': user.id,
'chat_id': chat_id,
'message_id': metadata.get('message_id'),
'model_id': metadata.get('model_id'),
'title': title,
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
'message': content,
},
message=title or 'Chat finished',
)
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id}})
# We believe in one maker of all models, seen and unseen,
# and in the reasoning which proceeds from the architect.
# We look for the resurrection of dead processes and the
@ -1880,6 +1925,7 @@ def apply_params_to_form_data(form_data, model):
'reasoning_tags': list,
'compact_token_threshold': int,
'system': str,
'note_id': str,
}
for key in list(params.keys()):
@ -1975,7 +2021,7 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[
return None
return [
{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files', 'contextSummary', 'usage')}
{k: v for k, v in msg.items() if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')}
for msg in db_messages
]
@ -2035,6 +2081,7 @@ def strip_compaction_fields(messages: list[dict]) -> list[dict]:
clean.pop('contextSummary', None)
clean.pop('context_summary', None)
clean.pop('usage', None)
clean.pop('id', None)
stripped.append(clean)
return stripped
@ -2073,7 +2120,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
return sanitized
SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)(?:\|[^>]*)?>')
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
def _get_text_parts(message: dict) -> list[str]:
@ -2087,27 +2134,31 @@ def _get_text_parts(message: dict) -> list[str]:
def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
"""Extract skill IDs from <$skillId|label> mention tags in messages."""
"""Extract skill IDs from <$skillId|label> and </skillId|label> mention tags."""
ids: set[str] = set()
for message in messages:
for text in _get_text_parts(message):
ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text))
ids.update(m.group(1) or m.group(2) for m in SKILL_MENTION_RE.finditer(text))
return ids
def strip_skill_mentions(messages: list[dict]) -> None:
"""Replace <$skillId|label> mention tags with the label in message content in-place."""
strip_re = re.compile(r'<\$[^|>]+(?:\|([^>]*))?>')
"""Replace <$skillId|label> and </skillId|label> mention tags with the label in-place."""
strip_re = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
def label(match):
return match.group(1) or match.group(2) or ''
for message in messages:
content = message.get('content')
if isinstance(content, str) and strip_re.search(content):
message['content'] = strip_re.sub(r'\1', content).strip()
message['content'] = strip_re.sub(label, content).strip()
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'text':
text = part.get('text', '')
if strip_re.search(text):
part['text'] = strip_re.sub(r'\1', text).strip()
part['text'] = strip_re.sub(label, text).strip()
async def connect_mcp_server(
@ -2223,7 +2274,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{
k: v
for k, v in assistant_message.items()
if k in ('role', 'content', 'output', 'files', 'contextSummary', 'usage')
if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')
}
)
@ -2353,23 +2404,23 @@ async def process_chat_payload(request, form_data, user, metadata, model):
folder_id = metadata.get('folder_id', None)
if folder_id and user:
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
folder = await Folders.get_folder_by_id(folder_id)
if folder and user.role != 'admin' and not await has_folder_access(user.id, folder, 'read', db=None):
folder = None
if folder and folder.data:
if 'system_prompt' in folder.data:
form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
if 'files' in folder.data:
# Defensive: filter to entries the caller can still read.
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
if metadata.get('params', {}).get('function_calling') == 'legacy':
form_data['files'] = [
*allowed_files,
{'type': 'folder', 'id': folder.id},
*form_data.get('files', []),
]
else:
# Native FC: skip RAG injection, builtin tools
# will read folder knowledge from metadata.
metadata['folder_knowledge'] = allowed_files
metadata['folder_knowledge'] = folder.data['files']
# Model "Knowledge" handling
user_message = get_last_user_message(form_data['messages'])
@ -2422,19 +2473,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e:
raise e
try:
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
if ENABLE_PLUGINS:
try:
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
form_data, flags = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type='inlet',
form_data=form_data,
extra_params=extra_params,
)
except Exception as e:
raise Exception(f'{e}')
form_data, flags = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type='inlet',
form_data=form_data,
extra_params=extra_params,
)
except Exception as e:
raise Exception(f'{e}')
features = form_data.pop('features', None) or {}
extra_params['__features__'] = features
@ -2516,7 +2568,36 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
available_skills = []
view_skill_ids = []
chat = None
if metadata.get('chat_id') and not metadata['chat_id'].startswith(('local:', 'channel:')):
chat = await Chats.get_chat_by_id(metadata['chat_id'])
if chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note':
note_id = (chat.meta or {}).get('note_id')
note = await Notes.get_note_by_id(note_id) if note_id else None
if note and (
user.role == 'admin'
or note.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
)
):
note_files = [
file
for file in ((note.data or {}).get('files') or [])
if isinstance(file, dict)
and file.get('type') != 'image'
and not (file.get('content_type') or '').startswith('image/')
]
if note_files:
files = [*(files or []), *note_files]
use_builtin_tools = (
chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note'
) or (
bool(metadata.get('session_id'))
and metadata.get('params', {}).get('function_calling') != 'legacy'
and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True)
@ -2574,31 +2655,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# urls = extract_urls(prompt)
if files:
if not files:
files = []
for file_item in files:
if file_item.get('type', 'file') == 'folder':
# Get folder files
folder_id = file_item.get('id', None)
if folder_id:
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
if folder and folder.data and 'files' in folder.data:
files = [f for f in files if f.get('id', None) != folder_id]
files = [*files, *await get_accessible_folder_files(folder.data['files'], user)]
# files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
# Remove duplicate files based on their content
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
metadata = {
**metadata,
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'terminal_id': terminal_id,
'files': files,
'features': features,
}
metadata.update(
{
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'skill_ids': list(skill_ids),
'terminal_id': terminal_id,
'files': files,
'features': features,
}
)
form_data['metadata'] = metadata
# When the caller provides an explicit `tools` key in the request body,
@ -2619,6 +2689,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
mcp_tools_dict = {}
if tool_ids:
db_tool_ids = []
for tool_id in tool_ids:
if tool_id.startswith('server:mcp:'):
try:
@ -2670,18 +2741,21 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
)
continue
elif ENABLE_PLUGINS:
db_tool_ids.append(tool_id)
tools_dict = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
'__model__': models[task_model_id],
'__messages__': form_data['messages'],
'__files__': metadata.get('files', []),
},
)
if db_tool_ids:
tools_dict = await get_tools(
request,
db_tool_ids,
user,
{
**extra_params,
'__model__': models[task_model_id],
'__messages__': form_data['messages'],
'__files__': metadata.get('files', []),
},
)
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
@ -2712,6 +2786,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=503, detail=f'Terminal unavailable: {e}') from e
if direct_tool_servers:
for tool_server in direct_tool_servers:
@ -3153,6 +3228,7 @@ async def background_tasks_handler(ctx):
{
'followUps': follow_ups,
},
touch=False,
)
except Exception as e:
@ -3372,16 +3448,19 @@ async def outlet_filter_handler(ctx):
'__model__': model,
}
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
if ENABLE_PLUGINS:
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
outlet_result, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type='outlet',
form_data=outlet_data,
extra_params=extra_params,
)
outlet_result, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type='outlet',
form_data=outlet_data,
extra_params=extra_params,
)
else:
outlet_result = outlet_data
if outlet_result and outlet_result.get('messages'):
if not is_temp_chat and messages_map:
@ -3465,6 +3544,7 @@ async def non_streaming_chat_response_handler(response, ctx):
{
'selectedModelId': response_data['selected_model_id'],
},
touch=False,
)
choices = response_data.get('choices', [])
@ -3547,22 +3627,7 @@ async def non_streaming_chat_response_handler(response, ctx):
},
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
},
)
await publish_chat_finished_event(request, user, metadata, title, content, response_output)
ctx['assistant_message'] = {
'content': content,
@ -3575,6 +3640,29 @@ async def non_streaming_chat_response_handler(response, ctx):
response = build_response_object(response, merge_events_into_response(response_data, events))
except Exception as e:
log.debug(f'Error occurred while processing request: {e}')
chat_id = metadata.get('chat_id')
if (
getattr(request.state, 'internal', False) is not True
and chat_id
and not chat_id.startswith(('channel:', 'local:'))
):
webui_url = await Config.get('webui.url')
await publish_event(
request,
EVENTS.CHAT_FAILED,
actor=user,
subject_id=chat_id,
subject_type='chat',
data={
'user_id': user.id,
'chat_id': chat_id,
'message_id': metadata.get('message_id'),
'model_id': metadata.get('model_id'),
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
'message': str(e),
},
message='Chat failed',
)
pass
return response
@ -3621,10 +3709,13 @@ async def streaming_chat_response_handler(response, ctx):
'__model__': model,
}
filter_functions = [
await Functions.get_function_by_id(filter_id)
for filter_id in await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
]
filter_functions = (
await Functions.get_functions_by_ids(
await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
)
if ENABLE_PLUGINS
else []
)
# Standard streaming response handler
# event_caller is optional — only needed for direct (client-side) tools
@ -4053,6 +4144,7 @@ async def streaming_chat_response_handler(response, ctx):
{
'selectedModelId': model_id,
},
touch=False,
)
await event_emitter(
{
@ -4398,7 +4490,12 @@ async def streaming_chat_response_handler(response, ctx):
user,
)
content = f'{content}{value}'
if isinstance(value, str):
# In-place append — avoids copying the full
# accumulated response on every chunk.
content += value
else:
content = f'{content}{value}'
# Check if we're inside a tag-based block
# (reasoning, code_interpreter, or solution).
@ -4505,16 +4602,17 @@ async def streaming_chat_response_handler(response, ctx):
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith(
'channel:'
):
current_output = full_output()
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'output': full_output(),
'output': current_output,
},
)
data = {
'output': full_output(),
'output': current_output,
}
delta_type = 'content'
else:
@ -4612,6 +4710,11 @@ async def streaming_chat_response_handler(response, ctx):
await response.background()
tool_call_iterations = 0
max_tool_call_iterations = getattr(
request.state,
'max_tool_call_iterations',
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS,
)
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations
user_message = get_last_user_message(form_data['messages'])
@ -4632,8 +4735,7 @@ async def streaming_chat_response_handler(response, ctx):
)
while tool_calls and (
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is None
or tool_call_iterations < CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
max_tool_call_iterations is None or tool_call_iterations < max_tool_call_iterations
):
tool_call_iterations += 1
@ -4670,83 +4772,92 @@ async def streaming_chat_response_handler(response, ctx):
results = []
def parse_tool_params(tool_call):
tool_args = tool_call.get('function', {}).get('arguments', '{}')
params = {}
if tool_args and tool_args.strip():
try:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
try:
params = json.loads(tool_args)
except Exception:
return None
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
return params
async def execute_tool_call(tool_call):
name = tool_call.get('function', {}).get('name', '')
params = parse_tool_params(tool_call)
if params is None:
return {}, None, None, None, False
tool = tools.get(name)
if not tool:
return params, f'Error: Tool "{name}" not found.', None, None, False
spec = tool.get('spec', {})
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
params = {key: value for key, value in params.items() if key in allowed_params}
try:
if direct_tool:
result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': name,
'params': params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id'),
},
}
)
else:
function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files', []),
},
)
result = await function(**params)
except Exception as e:
result = str(e)
return params, result, tool, tool_type, direct_tool
delegate_calls = [
tool_call
for tool_call in response_tool_calls
if tool_call.get('function', {}).get('name') == 'delegate_task'
]
tool_results = {}
for tool_call in response_tool_calls:
if tool_call.get('function', {}).get('name') != 'delegate_task':
tool_results[id(tool_call)] = await execute_tool_call(tool_call)
tool_results.update(
zip(
[id(tool_call) for tool_call in delegate_calls],
await asyncio.gather(*(execute_tool_call(tool_call) for tool_call in delegate_calls)),
)
)
for tool_call in response_tool_calls:
tool_call_id = tool_call.get('id', '')
tool_function_name = tool_call.get('function', {}).get('name', '')
tool_args = tool_call.get('function', {}).get('arguments', '{}')
tool_function_params = {}
if tool_args and tool_args.strip():
try:
# json.loads cannot be used because some models do not produce valid JSON
tool_function_params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(tool_args)
except Exception as e:
log.error(f'Error parsing tool call arguments: {tool_args}')
results.append(
{
'tool_call_id': tool_call_id,
'content': f'Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.',
}
)
continue
# Ensure arguments are valid JSON for downstream LLM integrations
log.debug(f'Parsed args from {tool_args} to {tool_function_params}')
tool_call.setdefault('function', {})['arguments'] = json.dumps(tool_function_params)
tool_result = None
tool = None
tool_type = None
direct_tool = False
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get('spec', {})
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
try:
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
tool_function_params = {
k: v for k, v in tool_function_params.items() if k in allowed_params
tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[id(tool_call)]
if tool_result is None:
results.append(
{
'tool_call_id': tool_call_id,
'content': (
'Error: Tool call arguments could not be parsed. The model generated '
f'malformed or incomplete JSON for `{tool_function_name}`. Please try again.'
),
}
if direct_tool:
tool_result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': tool_function_name,
'params': tool_function_params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id', None),
},
}
)
else:
tool_function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files', []),
},
)
tool_result = await tool_function(**tool_function_params)
except Exception as e:
tool_result = str(e)
else:
tool_result = f'Error: Tool "{tool_function_name}" not found.'
)
continue
tool_result, tool_result_files, tool_result_embeds = await process_tool_result(
request,
@ -5018,12 +5129,12 @@ async def streaming_chat_response_handler(response, ctx):
break
if (
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is not None
max_tool_call_iterations is not None
and tool_calls
and tool_call_iterations >= CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
and tool_call_iterations >= max_tool_call_iterations
):
log.warning('Tool-call iteration limit reached (%s)', CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS)
error_content = f'Tool-call limit reached ({CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS} iterations).'
log.warning('Tool-call iteration limit reached (%s)', max_tool_call_iterations)
error_content = f'Tool-call limit reached ({max_tool_call_iterations} iterations).'
if not metadata.get('chat_id', '').startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
@ -5248,22 +5359,7 @@ async def streaming_chat_response_handler(response, ctx):
{'done': True},
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
},
)
await publish_chat_finished_event(request, user, metadata, title, content, output)
await event_emitter(
{
@ -5308,6 +5404,7 @@ async def streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{'done': True},
touch=False,
)
try:

View file

@ -9,7 +9,7 @@ from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
DEFAULT_ARENA_MODEL,
)
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.functions import get_function_models
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
@ -68,6 +68,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'models.base_models_cache',
'evaluation.arena.enable',
'evaluation.arena.models',
'models.default_metadata',
)
if (
request.app.state.MODELS
@ -125,11 +126,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
]
models = models + arena_models
global_action_ids = {function.id for function in await Functions.get_global_action_functions()}
enabled_action_ids = {function.id for function in await Functions.get_functions_by_type('action', active_only=True)}
# One query per type: the global sets are subsets of the active sets, so
# deriving them from the same rows halves the function-table queries.
if ENABLE_PLUGINS:
active_actions = await Functions.get_functions_by_type('action', active_only=True)
global_action_ids = {function.id for function in active_actions if function.is_global}
enabled_action_ids = {function.id for function in active_actions}
global_filter_ids = {function.id for function in await Functions.get_global_filter_functions()}
enabled_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
global_filter_ids = {function.id for function in active_filters if function.is_global}
enabled_filter_ids = {function.id for function in active_filters}
else:
global_action_ids = set()
enabled_action_ids = set()
global_filter_ids = set()
enabled_filter_ids = set()
custom_models = await Models.get_all_models()
@ -157,8 +168,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if 'info' in model:
if 'meta' in model['info']:
action_ids.extend(model['info']['meta'].get('actionIds', []))
filter_ids.extend(model['info']['meta'].get('filterIds', []))
if ENABLE_PLUGINS:
action_ids.extend(model['info']['meta'].get('actionIds', []))
filter_ids.extend(model['info']['meta'].get('filterIds', []))
if 'params' in model['info']:
del model['info']['params']
@ -211,10 +223,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if custom_model.meta:
meta = custom_model.meta.model_dump()
if 'actionIds' in meta:
if ENABLE_PLUGINS and 'actionIds' in meta:
action_ids.extend(meta['actionIds'])
if 'filterIds' in meta:
if ENABLE_PLUGINS and 'filterIds' in meta:
filter_ids.extend(meta['filterIds'])
model['action_ids'] = action_ids
@ -292,7 +304,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = await Config.get('models.default_metadata', {}) or {}
default_metadata = config.get('models.default_metadata') or {}
if default_metadata:
for model in models:

View file

@ -0,0 +1,403 @@
from __future__ import annotations
import logging
import re
import time
from typing import Any
from urllib.parse import urlparse
from open_webui.events import EVENT_DEFINITIONS_BY_NAME, NOTIFICATION_EVENTS
from open_webui.models.config import Config
from open_webui.models.users import Users
from open_webui.retrieval.web.utils import validate_url
from open_webui.utils.webhook import post_webhook
VALID_EVENTS = set(NOTIFICATION_EVENTS)
LEGACY_EVENTS = {'chat.finished', 'chat.failed'}
VALID_DELIVERY = {'away', 'always'}
CHAT_FINISHED_EVENT = 'chat.finished'
CHAT_FAILED_EVENT = 'chat.failed'
CHANNEL_MESSAGE_EVENT = 'channel.message'
CALENDAR_ALERT_EVENT = 'calendar.alert'
DEFAULT_TARGET_ID = 'webhook'
DESCRIPTION_DEFAULT = object()
log = logging.getLogger(__name__)
def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
existing = existing or {}
now = int(time.time())
target_type = str(target.get('type') or existing.get('type') or 'webhook').strip()
if target_type != 'webhook':
raise ValueError('Unsupported notification target type')
config = dict(existing.get('config') or {})
config.update(target.get('config') or {})
url = str(config.get('url') or '').strip()
if not url:
raise ValueError('Webhook URL is required')
if '...' in url:
url = str((existing.get('config') or {}).get('url') or '').strip()
validate_url(url)
config['url'] = url
target_id = str(target.get('id') or existing.get('id') or '').strip()
if not target_id:
hostname = urlparse(url).hostname or 'webhook'
target_id = re.sub(r'[^a-zA-Z0-9_-]+', '-', hostname).strip('-').lower() or 'target'
events = target['events'] if 'events' in target else existing.get('events', [])
if events is None:
events = []
if not isinstance(events, list):
raise ValueError('events must be a list')
cleaned_events = []
for event in events:
event = str(event)
if event not in VALID_EVENTS:
raise ValueError(f'unsupported notification event: {event}')
if event not in cleaned_events:
cleaned_events.append(event)
delivery = str(target.get('delivery') or existing.get('delivery') or 'away').strip()
if delivery not in VALID_DELIVERY:
raise ValueError('Invalid notification delivery mode')
return {
'id': target_id,
'type': target_type,
'enabled': bool(target.get('enabled', existing.get('enabled', True))),
'events': cleaned_events,
'delivery': delivery,
'config': config,
'created_at': int(existing.get('created_at') or now),
'updated_at': now,
}
def _public_target(target: dict[str, Any], default_target_id: str | None = None) -> dict[str, Any]:
config = dict(target.get('config') or {})
url = str(config.pop('url', '') or '')
if url:
parsed = urlparse(url)
if parsed.hostname:
path = parsed.path or ''
suffix = path[-4:] if len(path) > 4 else path
config['url_masked'] = f'{parsed.scheme}://{parsed.hostname}/...{suffix}'
else:
config['url_masked'] = '****'
else:
config['url_masked'] = ''
return {**target, 'config': config, 'is_default': target.get('id') == default_target_id}
async def _load_notifications(user_id: str) -> dict[str, Any]:
user = await Users.get_user_by_id(user_id)
if not user:
raise ValueError('User not found')
settings = getattr(user, 'settings', None)
settings = settings.model_dump(exclude_none=True) if hasattr(settings, 'model_dump') else dict(settings or {})
notifications = dict(settings.get('notifications') or {})
targets = notifications.get('targets')
legacy_url = str(
notifications.get('webhook_url') or settings.get('ui', {}).get('notifications', {}).get('webhook_url') or ''
).strip()
if not isinstance(targets, list) or not targets:
if legacy_url:
target = _normalize_target(
{
'id': DEFAULT_TARGET_ID,
'type': 'webhook',
'enabled': True,
'events': sorted(VALID_EVENTS),
'delivery': 'away',
'config': {'url': legacy_url},
}
)
notifications = {
**notifications,
'targets': [target],
'default_target_id': DEFAULT_TARGET_ID,
'legacy_notification_events_migrated': True,
}
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
else:
notifications['targets'] = [target for target in targets if isinstance(target, dict)]
notifications.setdefault(
'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None
)
if legacy_url and not notifications.get('legacy_notification_events_migrated'):
changed = False
for target in notifications['targets']:
if (
target.get('id') == DEFAULT_TARGET_ID
and str((target.get('config') or {}).get('url') or '').strip() == legacy_url
and set(target.get('events') or []) == LEGACY_EVENTS
):
target['events'] = sorted(VALID_EVENTS)
changed = True
notifications['legacy_notification_events_migrated'] = True
if changed:
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return notifications
async def list_targets(user_id: str) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
default_target_id = notifications.get('default_target_id')
return {
'targets': [_public_target(target, default_target_id) for target in notifications.get('targets') or []],
}
async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
has_explicit_id = bool(str(payload.get('id') or '').strip())
target = _normalize_target(payload)
if any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets):
if has_explicit_id:
raise ValueError('notification target id already exists')
base = target['id']
suffix = 2
while any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets):
target['id'] = f'{base}-{suffix}'
suffix += 1
targets.append(target)
notifications['targets'] = targets
notifications.setdefault('default_target_id', target['id'])
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(target, notifications.get('default_target_id'))
async def update_target(user_id: str, target_id: str, payload: dict[str, Any]) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
for index, existing in enumerate(targets):
if str(existing.get('id', '')).lower() == target_id.lower():
updated = _normalize_target({'id': target_id, **payload}, existing=existing)
if any(
idx != index and str(target.get('id', '')).lower() == updated['id'].lower()
for idx, target in enumerate(targets)
):
raise ValueError('notification target id already exists')
targets[index] = updated
notifications['targets'] = targets
if str(notifications.get('default_target_id') or '').lower() == target_id.lower():
notifications['default_target_id'] = updated['id']
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(updated, notifications.get('default_target_id'))
raise ValueError('Notification target not found')
async def delete_target(user_id: str, target_id: str) -> bool:
notifications = await _load_notifications(user_id)
targets = notifications.get('targets') or []
next_targets = [target for target in targets if str(target.get('id', '')).lower() != target_id.lower()]
if len(next_targets) == len(targets):
return False
notifications['targets'] = next_targets
if str(notifications.get('default_target_id') or '').lower() == target_id.lower():
notifications['default_target_id'] = next_targets[0].get('id') if next_targets else None
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return True
async def set_default_target(user_id: str, target_id: str) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
for target in notifications.get('targets') or []:
if str(target.get('id', '')).lower() == target_id.lower():
notifications['default_target_id'] = target['id']
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(target, target['id'])
raise ValueError('Notification target not found')
def get_notification_event_catalog() -> list[dict[str, str]]:
return [
{
'event': event_name,
'label': EVENT_DEFINITIONS_BY_NAME[event_name].message or event_name,
'description': EVENT_DEFINITIONS_BY_NAME[event_name].description or '',
}
for event_name in NOTIFICATION_EVENTS
]
def _find_target(notifications: dict[str, Any], target: str = '') -> dict[str, Any] | None:
targets = notifications.get('targets') or []
target = target.strip()
target_id = target or str(notifications.get('default_target_id') or '')
if not target_id:
return None
for item in targets:
if str(item.get('id', '')).lower() == target_id.lower():
return item
return None
async def _send_webhook(
app_name: str,
target: dict[str, Any],
message: str,
data: dict[str, Any],
title: str = '',
description: str | None | object = DESCRIPTION_DEFAULT,
):
url = str((target.get('config') or {}).get('url') or '').strip()
if not url:
raise ValueError('Webhook URL is required')
if description is DESCRIPTION_DEFAULT:
description = message if title else None
ok = await post_webhook(app_name, url, title or message, data, description=description)
if not ok:
raise ValueError('Webhook delivery failed')
def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], str | None]:
data = event.data or {}
if event.event == CHAT_FINISHED_EVENT:
title = str(data.get('title') or event.message or 'Chat finished')
content = str(data.get('message') or '')
url = str(data.get('url') or '')
chat_id = str(data.get('chat_id') or '')
if chat_id and url.endswith(f'/c/{chat_id}'):
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
body = '\n'.join(part for part in (content, url) if part)
return (
f'**{title}**',
body,
{
'action': 'chat',
'message': content,
'title': title,
'url': url,
},
body,
)
if event.event == CHAT_FAILED_EVENT:
title = str(event.message or 'Chat failed')
content = str(data.get('message') or '')
url = str(data.get('url') or '')
chat_id = str(data.get('chat_id') or '')
if chat_id and url.endswith(f'/c/{chat_id}'):
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
body = '\n'.join(part for part in (content, url) if part)
return (
f'**{title}**',
body,
{
'action': 'chat_failed',
'message': content,
'title': title,
'url': url,
},
body,
)
if event.event == CHANNEL_MESSAGE_EVENT:
channel_name = str(data.get('title') or event.message or 'Channel')
content = str(data.get('content') or data.get('message') or '')
url = str(data.get('url') or '')
body = '\n'.join(part for part in (content, url) if part)
return (
f'**#{channel_name}**',
body,
{
'action': 'channel',
'message': content,
'title': channel_name,
'url': url,
},
body,
)
if event.event == CALENDAR_ALERT_EVENT:
title = str(data.get('title') or event.message or 'Calendar alert')
starts_in = str(data.get('starts_in') or '')
message = f'**{title}**\nstarting {starts_in}'.strip()
return (
'',
message,
{
'action': 'calendar_alert',
'title': title,
'minutes_until': data.get('minutes_until'),
'event_id': data.get('event_id') or (event.subject or {}).get('id'),
},
None,
)
definition = EVENT_DEFINITIONS_BY_NAME.get(event.event)
title = event.message or (definition.message if definition else event.event)
message = str(data.get('message') or data.get('preview') or data.get('content_preview') or title)
return str(title), message, event.model_dump(), message if title else None
async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]:
notifications = await _load_notifications(user_id)
target = _find_target(notifications, target_id)
if not target:
raise ValueError('Notification target not found')
await _send_webhook(
app_name,
target,
'This is a test notification from Open WebUI.',
{'action': 'test', 'user_id': user_id},
'Test notification',
)
return {'ok': True}
async def notify_target(
user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI'
) -> dict[str, Any]:
notifications = await _load_notifications(user_id)
item = _find_target(notifications, target)
if not item:
raise ValueError('Notification target not found')
if not item.get('enabled', True):
raise ValueError('Notification target is disabled')
await _send_webhook(
app_name,
item,
message,
{'action': 'notify', 'user_id': user_id, 'message': message, 'title': title},
title or 'Notification',
)
return {'ok': True, 'target_id': item.get('id')}
async def dispatch_notification_event(app: Any, event: Any) -> None:
if event.event not in VALID_EVENTS or not await Config.get('ui.enable_user_webhooks'):
return
from open_webui.events import event_user_ids
app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
for user_id in event_user_ids(event):
try:
notifications = await _load_notifications(user_id)
is_active = False if event.event == CHANNEL_MESSAGE_EVENT else await Users.is_user_active(user_id)
for target in notifications.get('targets') or []:
if not target.get('enabled', True):
continue
if event.event not in target.get('events', []):
continue
if target.get('delivery', 'away') == 'away' and is_active:
continue
title, message, data, description = _notification_webhook_content(event)
await _send_webhook(app_name, target, message, data, title, description=description)
except Exception:
log.exception('Notification delivery failed for user %s and event %s', user_id, event.event)

View file

@ -13,6 +13,7 @@ from typing import Any
from open_webui.env import (
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
ENABLE_PLUGINS,
OFFLINE_MODE,
PIP_OPTIONS,
PIP_PACKAGE_INDEX_OPTIONS,
@ -203,6 +204,9 @@ def replace_imports(content):
# May the intent of the one who wrote it survive every
# import and transformation, as a deed survives the generations.
async def load_tool_module_by_id(tool_id, content=None):
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
if content is None:
tool = await Tools.get_tool_by_id(tool_id)
if not tool:
@ -251,6 +255,9 @@ async def load_tool_module_by_id(tool_id, content=None):
async def load_function_module_by_id(function_id: str, content: str | None = None):
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
if content is None:
function = await Functions.get_function_by_id(function_id)
if not function:
@ -447,6 +454,10 @@ async def install_tool_and_function_dependencies():
and then installing them using pip. Duplicates or similar version specifications are
handled by pip as much as possible.
"""
if not ENABLE_PLUGINS:
log.info('ENABLE_PLUGINS is disabled, skipping tool and function dependencies.')
return
function_list = await Functions.get_functions(active_only=True)
tool_list = await Tools.get_tools()

View file

@ -2,15 +2,36 @@ import os
import re
from typing import Dict
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.datastructures import MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers.update(set_security_headers())
return response
class SecurityHeadersMiddleware:
"""Apply configured security headers to every HTTP response.
Pure ASGI to avoid BaseHTTPMiddleware's response re-buffering. See
open_webui.utils.asgi_middleware for the rationale.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
# Headers derive only from env vars, which are static for the process
# lifetime — compute them once instead of per response.
self._headers = list(set_security_headers().items())
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http' or not self._headers:
await self.app(scope, receive, send)
return
async def send_with_security_headers(message: Message) -> None:
if message['type'] == 'http.response.start':
headers = MutableHeaders(scope=message)
for key, value in self._headers:
headers[key] = value
await send(message)
await self.app(scope, receive, send_with_security_headers)
def set_security_headers() -> Dict[str, str]:

View file

@ -0,0 +1,672 @@
from __future__ import annotations
import asyncio
import copy
import json
import time
from datetime import timedelta
from uuid import uuid4
from fastapi import Request
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.internal.db import get_async_db
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chat, ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.users import UserModel, Users
from open_webui.tasks import create_task, has_active_tasks
from open_webui.utils.auth import create_token
from open_webui.utils.misc import get_message_list
from sqlalchemy import select
from starlette.datastructures import Headers
DEFAULT_SUBAGENT_SYSTEM_PROMPT = """You are a sub-agent working on a specific task assigned by the lead agent.
You have full access to the workspace — you can read, write, edit files, and run commands.
Focus exclusively on your assigned task. Do NOT work on anything outside your scope.
When done, end with a clear summary:
- What you did
- What files you changed (if any)
- Any issues or open questions
"""
MUTATING_MEMORY_TOOLS = {
'add_memory',
'delete_memory',
'replace_memory_content',
'update_memory',
}
_background_active: set[str] = set()
_background_lock = asyncio.Lock()
_foreground_semaphore: asyncio.Semaphore | None = None
_parent_locks: dict[str, asyncio.Lock] = {}
def _build_request(source: Request, user_id: str, *, internal: bool) -> Request:
scope = {
'type': 'http',
'asgi': {'version': '3.0', 'spec_version': '2.0'},
'method': 'POST',
'path': '/api/v1/subagents/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 0),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': source.app,
}
request = Request(scope)
token = create_token(
data={'id': user_id, 'typ': 'subagent'},
expires_delta=timedelta(hours=1),
)
request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=token)
request.state.enable_api_keys = False
if internal:
request.state.internal = True
return request
async def process_pending_internal_messages(
source_request: Request,
parent_chat_id: str,
user_id: str,
run: dict,
) -> None:
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
while await has_active_tasks(source_request.app.state.redis, parent_chat_id):
await asyncio.sleep(0.25)
async with lock:
if await has_active_tasks(source_request.app.state.redis, parent_chat_id):
return
user = await Users.get_user_by_id(user_id)
if not user:
return
async with get_async_db() as db:
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user_id)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update()
result = await db.execute(stmt)
chat = result.scalar_one_or_none()
if not chat:
return
history = copy.deepcopy((chat.chat or {}).get('history') or {})
messages = history.get('messages') or {}
pending = [
message
for message in messages.values()
for meta in [message.get('meta') or {}]
if message.get('role') == 'user'
and not message.get('childrenIds')
and (
(
meta.get('internal') is True
and meta.get('type') == 'subagent'
and meta.get('status') in (None, 'pending')
)
or (meta.get('internal') is True and meta.get('type') == 'timer')
)
]
if not pending:
return
first = pending[0]
first_meta = first.get('meta') or {}
kind = 'timer' if first_meta.get('internal') is True and first_meta.get('type') == 'timer' else 'subagent'
parent_id = first.get('parentId')
if kind == 'timer' and first_meta.get('timer_id'):
timer = await Chats.get_chat_by_id(first_meta['timer_id'])
run = {**run, **(((timer.meta or {}).get('run') if timer else None) or {})}
model_id = first.get('model') or run['model_id']
if kind == 'timer':
batch = [first]
else:
batch = [
message
for message in pending
for meta in [message.get('meta') or {}]
if message.get('parentId') == parent_id
and (message.get('model') or model_id) == model_id
and (
meta.get('internal') is True
and meta.get('type') == 'subagent'
and meta.get('status') in (None, 'pending')
)
]
combined_content = '\n\n'.join(message.get('content', '') for message in batch if message.get('content'))
if kind == 'timer':
timer_ids = [
message['meta']['timer_id'] for message in batch if (message.get('meta') or {}).get('timer_id')
]
combined_meta = {'internal': True, 'type': 'timer'}
if len(timer_ids) == 1:
combined_meta['timer_id'] = timer_ids[0]
elif timer_ids:
combined_meta['timer_ids'] = timer_ids
else:
delegation_ids = [
message['meta']['delegation_id']
for message in batch
if (message.get('meta') or {}).get('delegation_id')
]
subagent_chat_ids = [
message['meta']['subagent_chat_id']
for message in batch
if (message.get('meta') or {}).get('subagent_chat_id')
]
combined_meta = {'internal': True, 'type': 'subagent'}
if len(delegation_ids) == 1:
combined_meta['delegation_id'] = delegation_ids[0]
elif delegation_ids:
combined_meta['delegation_ids'] = delegation_ids
if len(subagent_chat_ids) == 1:
combined_meta['subagent_chat_id'] = subagent_chat_ids[0]
elif subagent_chat_ids:
combined_meta['subagent_chat_ids'] = subagent_chat_ids
reuse_message = len(batch) == 1 and (first.get('meta') or {}).get('status') != 'pending'
user_message_id = first['id'] if reuse_message else str(uuid4())
removed_ids = set()
if not reuse_message:
removed_ids = {message['id'] for message in batch}
for message_id in removed_ids:
messages.pop(message_id, None)
if parent_id and parent_id in messages:
messages[parent_id]['childrenIds'] = [
child_id
for child_id in messages[parent_id].get('childrenIds', [])
if child_id not in removed_ids
]
assistant_message_id = str(uuid4())
message_list = get_message_list(messages, parent_id)
system_prompt = run.get('system_prompt')
user_message = {
'id': user_message_id,
'parentId': parent_id,
'childrenIds': [assistant_message_id],
'role': 'user',
'content': combined_content,
'model': model_id,
'meta': combined_meta,
'timestamp': int(time.time()),
}
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': model_id,
'timestamp': int(time.time()),
}
if parent_id and parent_id in messages:
parent_children = [
child_id for child_id in messages[parent_id].get('childrenIds', []) if child_id != user_message_id
]
parent_children.append(user_message_id)
messages[parent_id]['childrenIds'] = parent_children
messages[user_message_id] = {**messages.get(user_message_id, {}), **user_message}
messages[assistant_message_id] = assistant_message
history['messages'] = messages
history['currentId'] = assistant_message_id
chat.chat = {**(chat.chat or {}), 'history': history}
chat.updated_at = int(time.time())
await db.commit()
if removed_ids:
await ChatMessages.delete_message_ids_by_chat_id(parent_chat_id, removed_ids)
await ChatMessages.upsert_message(user_message_id, parent_chat_id, user_id, user_message)
await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, user_id, assistant_message)
from open_webui.socket.main import sio
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': assistant_message_id,
'data': {'type': 'chat:reload'},
},
room=f'user:{user.id}',
)
form_data = {
'model': model_id,
'messages': [
*([{'role': 'system', 'content': system_prompt}] if system_prompt else []),
*message_list,
{'role': 'user', 'content': combined_content},
],
'stream': True,
'chat_id': parent_chat_id,
'id': assistant_message_id,
'parent_id': parent_id,
'user_message': user_message,
'session_id': run.get('session_id') or f'{kind}-result:{parent_chat_id}',
'background_tasks': {},
'tool_ids': run.get('tool_ids') or [],
'skill_ids': run.get('skill_ids') or [],
'filter_ids': run.get('filter_ids') or [],
'features': run.get('features') or {},
'files': run.get('files') or [],
'variables': run.get('variables') or {},
}
if run.get('terminal_id'):
form_data['terminal_id'] = run['terminal_id']
request = _build_request(source_request, user.id, internal=False)
await source_request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
async def delegate(
task: str,
context: str,
background: bool,
*,
request: Request,
user_data: dict,
metadata: dict,
parent_chat_id: str,
parent_message_id: str | None,
) -> str:
global _foreground_semaphore
task = task.strip()
if not task:
return 'Error: task must not be empty.'
if not parent_chat_id or not user_data.get('id'):
return 'Error: chat and user context are required.'
config = await Config.get_many(
'subagents.background_enabled',
'subagents.max_concurrent',
'subagents.max_async',
'subagents.max_iterations',
'subagents.max_output',
'subagents.system_prompt',
)
max_concurrent = int(config.get('subagents.max_concurrent') or 20)
max_async = int(config.get('subagents.max_async') or 20)
max_iterations = int(config.get('subagents.max_iterations') or 30)
max_output = int(config.get('subagents.max_output') or 30_000)
if max_concurrent != -1:
max_concurrent = max(1, max_concurrent)
if max_async != -1:
max_async = max(1, max_async)
if background and not config.get('subagents.background_enabled'):
return 'Error: background sub-agents are disabled in settings.'
features = copy.deepcopy(metadata.get('features') or {})
if (
background
and features.get('code_interpreter')
and await Config.get('code_interpreter.engine', 'pyodide') != 'jupyter'
):
features.pop('code_interpreter')
run = {
'model_id': metadata.get('model_id') or (metadata.get('model') or {}).get('id'),
'session_id': metadata.get('session_id'),
'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []),
'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []),
'system_prompt': metadata.get('system_prompt'),
'tool_servers': [] if background else copy.deepcopy(metadata.get('tool_servers') or []),
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
'terminal_id': metadata.get('terminal_id'),
'features': features,
'files': copy.deepcopy(metadata.get('files') or []),
'variables': copy.deepcopy(metadata.get('variables') or {}),
'direct': bool(metadata.get('direct')),
}
if not run.get('model_id'):
return 'Error: model context is required.'
if run.get('direct'):
return 'Error: sub-agents are unavailable for direct connections.'
delegation_id = f'deleg_{uuid4().hex[:8]}'
foreground_semaphore = None
if background:
async with _background_lock:
if max_async != -1 and len(_background_active) >= max_async:
return (
f'Error: Async subagent capacity reached ({max_async} running). '
'Wait for one to finish or increase subagents.max_async.'
)
_background_active.add(delegation_id)
elif max_concurrent != -1:
if _foreground_semaphore is None:
_foreground_semaphore = asyncio.Semaphore(max_concurrent)
foreground_semaphore = _foreground_semaphore
await foreground_semaphore.acquire()
mode = 'background' if background else 'foreground'
try:
user = UserModel(**user_data)
chat_id = str(uuid4())
user_message_id = str(uuid4())
assistant_message_id = str(uuid4())
prompt = f'{task}\n\n## Context\n{context}' if context else task
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': f'Sub-agent: {task[:60]}',
'models': [run['model_id']],
'history': {
'currentId': assistant_message_id,
'messages': {
user_message_id: {
'id': user_message_id,
'parentId': None,
'childrenIds': [assistant_message_id],
'role': 'user',
'content': prompt,
'timestamp': int(time.time()),
'models': [run['model_id']],
},
assistant_message_id: {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': run['model_id'],
'timestamp': int(time.time()),
},
},
},
'messages': [{'role': 'user', 'content': prompt}],
}
),
internal_meta={
'internal': True,
'type': 'subagent',
'parent_chat_id': parent_chat_id,
'parent_message_id': parent_message_id,
'delegation_id': delegation_id,
'mode': mode,
},
)
if not chat:
raise RuntimeError('Failed to create sub-agent chat')
except Exception as exc:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
prefix = 'background ' if background else ''
return f'Error: failed to create {prefix}sub-agent: {exc}'
async def run_reserved() -> dict:
try:
child_request = _build_request(request, user.id, internal=True)
child_request.state.max_tool_call_iterations = max_iterations
parent_system_prompt = run.get('system_prompt') or ''
subagent_system_prompt = (
str(config.get('subagents.system_prompt') or '').strip() or DEFAULT_SUBAGENT_SYSTEM_PROMPT
)
form_data = {
'model': run['model_id'],
'messages': [
{
'role': 'system',
'content': (
f'{parent_system_prompt}\n\n{subagent_system_prompt}'
if parent_system_prompt
else subagent_system_prompt
),
},
{'role': 'user', 'content': prompt},
],
'stream': True,
'chat_id': chat_id,
'id': assistant_message_id,
'parent_id': None,
'user_message': {
'id': user_message_id,
'parentId': None,
'role': 'user',
'content': prompt,
},
'session_id': run.get('session_id') or f'subagent:{chat_id}',
'background_tasks': {},
'tool_ids': run.get('tool_ids') or [],
'skill_ids': run.get('skill_ids') or [],
'filter_ids': run.get('filter_ids') or [],
'features': run.get('features') or {},
'files': run.get('files') or [],
'variables': run.get('variables') or {},
}
if run.get('terminal_id'):
form_data['terminal_id'] = run['terminal_id']
if run.get('tool_servers'):
form_data['tool_servers'] = run['tool_servers']
await request.app.state.CHAT_COMPLETION_HANDLER(child_request, form_data, user=user)
message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
if not message:
return {
'status': 'error',
'summary': '',
'error': 'Sub-agent chat or completion message no longer exists.',
}
summary = message.get('content') or ''
if isinstance(summary, list):
summary = ''.join(
str(item.get('text', ''))
for item in summary
if isinstance(item, dict) and item.get('type') == 'text'
)
if not summary:
summary = ''.join(
str(part.get('text', ''))
for item in message.get('output') or []
if item.get('type') == 'message'
for part in item.get('content') or []
if part.get('type') == 'output_text'
)
if len(summary) > max_output:
summary = f'{summary[:max_output]}\n\n[output truncated]'
error = message.get('error')
return {
'status': 'error' if error else 'completed',
'summary': summary or ('Sub-agent produced no output.' if not error else ''),
'error': error,
}
except asyncio.CancelledError:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{'done': True, 'error': {'content': 'Sub-agent cancelled.'}},
)
raise
except Exception as exc:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{'done': True, 'error': {'content': str(exc)}},
)
raise
finally:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
async def run_background() -> dict:
started_at = time.time()
cancelled = False
try:
result = await run_reserved()
except asyncio.CancelledError:
result = {'status': 'interrupted', 'summary': '', 'error': 'cancelled'}
cancelled = True
except Exception as exc:
result = {'status': 'error', 'summary': '', 'error': str(exc)}
duration = f'{time.time() - started_at:.1f}s'
lines = [
f'[ASYNC SUBAGENT COMPLETE - {delegation_id}]',
(
'A background subagent you dispatched earlier has finished. '
'The original task source is included so you can decide whether '
'to use the result or continue without it.'
),
'',
f'Original task: {task}',
]
if context:
lines.append(f'Context provided: {context}')
lines.extend(
[
f'Subagent chat: {chat_id}',
f'Status: {result.get("status", "completed")} Duration: {duration}',
'--- RESULT ---',
]
)
if result.get('status') == 'completed':
lines.append(result.get('summary') or 'Subagent completed without a final summary.')
elif result.get('status') == 'interrupted':
lines.append('The subagent was interrupted before completing.')
if result.get('summary'):
lines.extend(['Partial output:', result['summary']])
else:
detail = f' {result.get("error")}' if result.get('error') else ''
lines.append(f'The subagent did not complete successfully.{detail}')
if result.get('summary'):
lines.extend(['Partial output:', result['summary']])
pending_message_id = str(uuid4())
pending_meta = {
'internal': True,
'type': 'subagent',
'delegation_id': delegation_id,
'subagent_chat_id': chat_id,
}
pending_message = {
'id': pending_message_id,
'parentId': None,
'childrenIds': [],
'role': 'user',
'content': '\n'.join(lines),
'model': run['model_id'],
'meta': pending_meta,
'timestamp': int(time.time()),
}
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
async with lock:
async with get_async_db() as db:
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user.id)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update()
result_row = await db.execute(stmt)
parent = result_row.scalar_one_or_none()
if not parent:
if cancelled:
raise asyncio.CancelledError
return result
updated_chat = copy.deepcopy(parent.chat or {})
updated_history = updated_chat.setdefault('history', {})
updated_messages = updated_history.setdefault('messages', {})
done_assistants = [
message
for message in updated_messages.values()
if message.get('role') == 'assistant' and message.get('done') is not False
]
result_parent_id = (
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
if done_assistants
else parent_message_id
)
pending_message['parentId'] = result_parent_id
if await has_active_tasks(request.app.state.redis, parent_chat_id):
pending_message['meta']['status'] = 'pending'
updated_messages[pending_message_id] = pending_message
if result_parent_id and result_parent_id in updated_messages:
children = updated_messages[result_parent_id].setdefault('childrenIds', [])
if pending_message_id not in children:
children.append(pending_message_id)
updated_history['messages'] = updated_messages
parent.chat = {**(parent.chat or {}), **updated_chat, 'history': updated_history}
parent.updated_at = int(time.time())
await db.commit()
await ChatMessages.upsert_message(
message_id=pending_message_id,
chat_id=parent_chat_id,
user_id=user.id,
data=pending_message,
)
if pending_message['meta'].get('status') == 'pending':
from open_webui.socket.main import sio
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': pending_message_id,
'data': {'type': 'chat:reload'},
},
room=f'user:{user.id}',
)
if not await has_active_tasks(request.app.state.redis, parent_chat_id):
await process_pending_internal_messages(request, parent_chat_id, user.id, run)
if cancelled:
raise asyncio.CancelledError
return result
try:
_, child_task = await create_task(
request.app.state.redis,
run_background() if background else run_reserved(),
id=chat_id,
)
except Exception as exc:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
return f'Error: {exc}'
if background:
return json.dumps(
{
'status': 'dispatched',
'delegation_id': delegation_id,
'subagent_chat_id': chat_id,
'mode': 'background',
'task': task,
},
ensure_ascii=False,
)
try:
result = await child_task
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
return 'Error: sub-agent was cancelled.'
except Exception as exc:
return f'Error: {exc}'
if result.get('status') != 'completed':
return f'Error: {result.get("error") or "sub-agent failed."}'
return result.get('summary') or 'Sub-agent produced no output.'

View file

@ -0,0 +1,16 @@
"""Shared routing helpers for admin-configured terminal servers."""
from urllib.parse import quote
def get_terminal_server_url(connection: dict) -> str:
"""Return the upstream base URL for a terminal connection.
An explicit policy uses the named-policy route. Connections without one
keep their existing root route.
"""
base_url = str(connection.get('url') or '').rstrip('/')
policy_id = str(connection.get('policy_id') or '').strip()
if policy_id:
return f'{base_url}/p/{quote(policy_id, safe="")}'
return base_url

View file

@ -0,0 +1,414 @@
"""Durable one-shot timers backed by internal child chats."""
from __future__ import annotations
import asyncio
import copy
import json
import logging
import re
import time
from datetime import datetime, timezone
from typing import Literal
from uuid import uuid4
from fastapi import Request
from sqlalchemy import select
from starlette.datastructures import Headers
from open_webui.internal.db import get_async_db
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chat, ChatForm, Chats
from open_webui.models.users import UserModel, Users
from open_webui.tasks import has_active_tasks
from open_webui.utils.misc import get_message_list
log = logging.getLogger(__name__)
_RELATIVE_TIME = re.compile(r'^(?:\+|in\s+)?(\d+)\s*(s|sec(?:onds?)?|m|min(?:utes?)?|h|hours?|d|days?)$')
_RFC3339_TIME = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$')
_TIME_UNITS_NS = {
's': 1_000_000_000,
'm': 60 * 1_000_000_000,
'h': 60 * 60 * 1_000_000_000,
'd': 24 * 60 * 60 * 1_000_000_000,
}
_timer_locks: dict[str, asyncio.Lock] = {}
def parse_timer_at(value: str) -> int:
"""Normalize a relative offset or timezone-aware RFC 3339 timestamp."""
raw = value.strip()
now = time.time_ns()
relative = _RELATIVE_TIME.fullmatch(raw.lower())
if relative:
count = int(relative.group(1))
if count <= 0:
raise ValueError('at must be in the future.')
return now + count * _TIME_UNITS_NS[relative.group(2)[0]]
if not _RFC3339_TIME.fullmatch(raw):
raise ValueError(
'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.'
)
try:
parsed = datetime.fromisoformat(raw.replace('Z', '+00:00'))
except ValueError as exc:
raise ValueError(
'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.'
) from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError('absolute at values must include an explicit timezone.')
due_at = int(parsed.timestamp() * 1_000_000_000)
if due_at <= now:
raise ValueError('at must be in the future.')
return due_at
async def create_timer(
*,
prompt: str,
at: str,
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None,
request: Request,
user_data: dict,
metadata: dict,
parent_chat_id: str,
parent_message_id: str | None,
) -> str:
prompt = prompt.strip()
if not prompt:
return 'Error: prompt must not be empty.'
if not parent_chat_id or not user_data.get('id'):
return 'Error: chat and user context are required.'
try:
due_at = parse_timer_at(at)
except ValueError as exc:
return f'Error: {exc}'
selected_events = cancel_on or []
allowed_events = {'chat.read', 'chat.user_message'}
if any(event not in allowed_events for event in selected_events):
return 'Error: cancel_on accepts only chat.read and chat.user_message.'
selected_events = list(dict.fromkeys(selected_events))
model_id = metadata.get('model_id') or (metadata.get('model') or {}).get('id')
if not model_id:
return 'Error: model context is required.'
if metadata.get('direct'):
return 'Error: timers are unavailable for direct connections.'
chat_id = str(uuid4())
user_message_id = str(uuid4())
user = UserModel(**user_data)
run = {
'model_id': model_id,
'session_id': metadata.get('session_id'),
'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []),
'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []),
'system_prompt': metadata.get('system_prompt'),
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
'terminal_id': metadata.get('terminal_id'),
'features': copy.deepcopy(metadata.get('features') or {}),
'files': copy.deepcopy(metadata.get('files') or []),
'variables': copy.deepcopy(metadata.get('variables') or {}),
}
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': f'Timer: {prompt[:60]}',
'models': [model_id],
'history': {
'currentId': user_message_id,
'messages': {
user_message_id: {
'id': user_message_id,
'parentId': None,
'childrenIds': [],
'role': 'user',
'content': prompt,
'timestamp': int(time.time()),
'models': [model_id],
},
},
},
'messages': [{'role': 'user', 'content': prompt}],
}
),
internal_meta={
'internal': True,
'type': 'timer',
'parent_chat_id': parent_chat_id,
'parent_message_id': parent_message_id,
'timer_at': due_at,
'status': 'pending',
'timer_model_id': model_id,
'timer_task_message_id': user_message_id,
'cancel_on': selected_events,
'run': run,
},
)
if not chat:
return 'Error: failed to create timer.'
return json.dumps(
{
'status': 'set',
'at': datetime.fromtimestamp(due_at / 1_000_000_000, timezone.utc).isoformat().replace('+00:00', 'Z'),
'cancel_on': selected_events,
},
ensure_ascii=False,
)
async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str]]:
"""Claim due timers by moving them from pending to running."""
async with get_async_db() as db:
stmt = (
select(Chat)
.where(Chat.meta['internal'].as_boolean().is_(True))
.where(Chat.meta['type'].as_string() == 'timer')
.where(Chat.meta['status'].as_string() == 'pending')
)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update(skip_locked=True)
result = await db.execute(stmt)
rows = [row for row in result.scalars().all() if int((row.meta or {}).get('timer_at') or 0) <= now_ns]
rows.sort(key=lambda row: int((row.meta or {}).get('timer_at') or 0))
rows = rows[:limit]
claimed = []
for row in rows:
claim_id = str(uuid4())
row.meta = {
**(row.meta or {}),
'status': 'running',
'timer_started_at': now_ns,
'timer_claim_id': claim_id,
}
row.updated_at = int(time.time())
claimed.append((row.id, claim_id))
await db.commit()
return claimed
async def cancel_timers_for_chat(parent_chat_id: str, event: Literal['chat.read', 'chat.user_message']) -> None:
async with get_async_db() as db:
result = await db.execute(
select(Chat)
.where(Chat.meta['internal'].as_boolean().is_(True))
.where(Chat.meta['type'].as_string() == 'timer')
.where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id)
.where(Chat.meta['status'].as_string() == 'pending')
)
now_ns = int(time.time_ns())
for row in result.scalars().all():
meta = row.meta or {}
if event not in (meta.get('cancel_on') or []):
continue
row.meta = {
**meta,
'status': 'cancelled',
'timer_cancelled_at': now_ns,
'timer_cancelled_by': event,
}
row.updated_at = int(time.time())
await db.commit()
async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> None:
lock = _timer_locks.setdefault(timer_id, asyncio.Lock())
async with lock:
from open_webui.socket.main import sio
from open_webui.utils.subagents import _parent_locks
timer = await Chats.get_chat_by_id(timer_id)
if not timer:
return
meta = timer.meta or {}
if meta.get('status') != 'running':
return
if claim_id is not None and meta.get('timer_claim_id') != claim_id:
return
parent_chat_id = meta.get('parent_chat_id') or ''
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, timer.user_id)
if not parent:
await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists')
return
prompt_message_id = meta.get('timer_task_message_id')
prompt_message = await Chats.get_message_by_id_and_message_id(timer_id, prompt_message_id)
if not prompt_message:
await _set_timer_state(timer_id, 'error', timer_error='timer task message is missing')
return
user = await Users.get_user_by_id(timer.user_id)
if not user:
await _set_timer_state(timer_id, 'error', timer_error='timer user no longer exists')
return
run = meta.get('run') or {}
model_id = run.get('model_id') or meta.get('timer_model_id')
if not model_id:
await _set_timer_state(timer_id, 'error', timer_error='model context is missing')
return
prompt = prompt_message.get('content') or ''
if isinstance(prompt, list):
prompt = ''.join(
str(part.get('text', '')) for part in prompt if isinstance(part, dict) and part.get('type') == 'text'
)
user_message_id = str(uuid4())
assistant_message_id = str(uuid4())
user_message = None
assistant_message = None
message_list = []
parent_lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
async with parent_lock:
async with get_async_db() as db:
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == timer.user_id)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update()
result = await db.execute(stmt)
parent = result.scalar_one_or_none()
if not parent:
await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists')
return
if await has_active_tasks(app.state.redis, parent_chat_id):
timer_row = await db.get(Chat, timer_id)
if timer_row:
timer_row.meta = {
**(timer_row.meta or {}),
'status': 'pending',
'timer_claim_id': None,
'timer_started_at': None,
}
timer_row.updated_at = int(time.time())
await db.commit()
return
parent_chat = copy.deepcopy(parent.chat or {})
history = parent_chat.setdefault('history', {})
messages = history.setdefault('messages', {})
done_assistants = [
message
for message in messages.values()
if message.get('role') == 'assistant' and message.get('done') is not False
]
parent_id = (
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
if done_assistants
else meta.get('parent_message_id')
)
message_list = get_message_list(messages, parent_id)
user_message = {
'id': user_message_id,
'parentId': parent_id,
'childrenIds': [assistant_message_id],
'role': 'user',
'content': prompt,
'model': model_id,
'meta': {'internal': True, 'type': 'timer', 'timer_id': timer_id},
'timestamp': int(time.time()),
}
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': model_id,
'timestamp': int(time.time()),
}
messages[user_message_id] = user_message
messages[assistant_message_id] = assistant_message
if parent_id and parent_id in messages:
children = messages[parent_id].setdefault('childrenIds', [])
if user_message_id not in children:
children.append(user_message_id)
parent.chat = parent_chat
history['currentId'] = assistant_message_id
parent.updated_at = int(time.time())
timer_row = await db.get(Chat, timer_id)
if timer_row:
timer_row.meta = {
**(timer_row.meta or {}),
'status': 'completed',
'timer_completed_at': int(time.time_ns()),
}
timer_row.updated_at = int(time.time())
await db.commit()
await ChatMessages.upsert_message(user_message_id, parent_chat_id, timer.user_id, user_message)
await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, timer.user_id, assistant_message)
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': assistant_message_id,
'data': {'type': 'chat:reload'},
},
room=f'user:{timer.user_id}',
)
form_data = {
'model': model_id,
'messages': [
*([{'role': 'system', 'content': run.get('system_prompt')}] if run.get('system_prompt') else []),
*message_list,
{'role': 'user', 'content': prompt},
],
'stream': True,
'chat_id': parent_chat_id,
'id': assistant_message_id,
'parent_id': user_message.get('parentId'),
'user_message': user_message,
'session_id': run.get('session_id') or f'timer:{parent_chat_id}',
'background_tasks': {},
'tool_ids': run.get('tool_ids') or [],
'skill_ids': run.get('skill_ids') or [],
'filter_ids': run.get('filter_ids') or [],
'features': run.get('features') or {},
'files': run.get('files') or [],
'variables': run.get('variables') or {},
}
if run.get('terminal_id'):
form_data['terminal_id'] = run['terminal_id']
request = Request(
{
'type': 'http',
'asgi': {'version': '3.0', 'spec_version': '2.0'},
'method': 'POST',
'path': '/api/v1/timers/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 0),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': app,
}
)
request.state.token = None
request.state.enable_api_keys = False
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
async def _set_timer_state(timer_id: str, status: str, **fields) -> None:
async with get_async_db() as db:
row = await db.get(Chat, timer_id)
if not row:
return
row.meta = {**(row.meta or {}), 'status': status, **fields}
row.updated_at = int(time.time())
await db.commit()

View file

@ -35,11 +35,13 @@ from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
ENABLE_FORWARD_USER_INFO_HEADERS,
ENABLE_PLUGINS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
REDIS_KEY_PREFIX,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.tools import Tools
@ -50,6 +52,7 @@ from open_webui.tools.builtin import (
create_automation,
create_calendar_event,
create_tasks,
delegate_task,
delete_automation,
delete_calendar_event,
delete_memory,
@ -65,6 +68,7 @@ from open_webui.tools.builtin import (
list_knowledge_bases,
list_memories,
list_memory_paths,
notify,
query_knowledge_bases,
query_knowledge_files,
read_memory_path,
@ -79,6 +83,7 @@ from open_webui.tools.builtin import (
search_memories,
search_notes,
search_web,
timer,
toggle_automation,
update_automation,
update_calendar_event,
@ -97,6 +102,7 @@ from open_webui.utils.access_control import has_access, has_connection_access, h
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.misc import is_string_allowed
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
from open_webui.utils.terminals import get_terminal_server_url
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
@ -253,6 +259,9 @@ async def get_updated_tool_function(function: Callable, extra_params: dict):
async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]:
"""Load tools for the given tool_ids, checking access control."""
if not ENABLE_PLUGINS:
return {}
if not tool_ids:
return {}
@ -495,6 +504,9 @@ async def get_builtin_tools(
'channels.enable',
'automations.enable',
'calendar.enable',
'ui.enable_user_webhooks',
'subagents.enable',
'subagents.background_enabled',
)
async def has_user_permission(feature_key: str) -> bool:
@ -559,6 +571,14 @@ async def get_builtin_tools(
if is_builtin_tool_enabled('chats'):
builtin_functions.extend([search_chats, view_chat])
if (
is_builtin_tool_enabled('subagents')
and config.get('subagents.enable')
and getattr(request.state, 'internal', False) is not True
and getattr(request.state, 'direct', False) is not True
):
builtin_functions.extend([delegate_task, timer])
# Add memory tools when memory is enabled and the model allows this builtin category.
if (
is_builtin_tool_enabled('memory')
@ -589,7 +609,8 @@ async def get_builtin_tools(
):
builtin_functions.extend([search_web, fetch_url])
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
# Add image generation/edit tools if builtin category enabled,
# globally enabled, and allowed by model capability.
if (
is_builtin_tool_enabled('image_generation')
and config.get('image_generation.enable')
@ -607,7 +628,8 @@ async def get_builtin_tools(
):
builtin_functions.append(edit_image)
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
# Add code interpreter tool if builtin category enabled,
# globally enabled, and allowed by model capability.
if (
is_builtin_tool_enabled('code_interpreter')
and config.get('code_interpreter.enable')
@ -617,8 +639,16 @@ async def get_builtin_tools(
):
builtin_functions.append(execute_code)
metadata = extra_params.get('__metadata__') or {}
chat_id = metadata.get('chat_id') or ''
chat = None
if chat_id and not chat_id.startswith(('local:', 'channel:')):
chat = await Chats.get_chat_by_id(chat_id)
# Notes tools - search, view, create, and update user's notes
if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'):
if (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') or (
is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes')
):
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
# Channels tools - search channels and messages
@ -656,6 +686,18 @@ async def get_builtin_tools(
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
)
if (
is_builtin_tool_enabled('notifications')
and config.get('ui.enable_user_webhooks')
and await has_user_permission('webhooks')
):
builtin_functions.append(notify)
if getattr(request.state, 'internal', False) is True:
from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS
builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS]
for func in builtin_functions:
callable = await get_async_tool_function_and_apply_extra_params(
func,
@ -675,6 +717,11 @@ async def get_builtin_tools(
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
spec = clean_openai_tool_schema(spec)
if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'):
parameters = spec.get('parameters', {})
parameters.get('properties', {}).pop('background', None)
if isinstance(parameters.get('required'), list):
parameters['required'] = [name for name in parameters['required'] if name != 'background']
tools_dict[func.__name__] = {
'tool_id': f'builtin:{func.__name__}',
@ -1071,7 +1118,9 @@ async def get_terminal_system_prompt(
trust_env=True,
) as session:
# 1. Check feature flag
async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
async with session.get(
f'{base}/api/config', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status != 200:
return None
config = await resp.json()
@ -1103,13 +1152,7 @@ async def set_terminal_servers(request: Request):
enabled = connection.get('enabled', True)
base_url = connection.get('url', '').rstrip('/')
policy_id = connection.get('policy_id', '')
# Orchestrator connections route through /p/{policy_id}/ — the
# OpenAPI spec lives on the proxied terminal, not the orchestrator.
if connection.get('server_type') == 'orchestrator' and policy_id:
base_url = f'{base_url}/p/{policy_id}'
base_url = get_terminal_server_url(connection)
server_configs.append(
{
@ -1139,6 +1182,8 @@ async def set_terminal_servers(request: Request):
headers = {}
if connection.get('auth_type', 'bearer') == 'bearer':
headers.update(bearer_auth_header(connection.get('key', '')))
if connection.get('policy_id'):
headers['X-User-Id'] = 'system'
prompt = await get_terminal_system_prompt(server['url'], headers)
if prompt:
server['system_prompt'] = prompt
@ -1188,24 +1233,21 @@ async def get_terminal_tools(
connections = await Config.get('terminal_server.connections', []) or []
connection = next((c for c in connections if c.get('id') == terminal_id), None)
if connection is None:
log.warning(f'Terminal server not found: {terminal_id}')
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
log.warning(f'Access denied to terminal {terminal_id} for user {user.id}')
return {}
raise RuntimeError(f'Access denied to terminal {terminal_id}')
# Find the cached spec data for this terminal
terminal_servers = await get_terminal_servers(request)
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
if server_data is None:
log.warning(f'Terminal server spec not found for {terminal_id}')
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
specs = server_data.get('specs', [])
if not specs:
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools")
# Build auth headers
auth_type = connection.get('auth_type', 'bearer')
@ -1232,7 +1274,7 @@ async def get_terminal_tools(
if session_id:
headers['X-Session-Id'] = session_id
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
terminal_cwd = await get_terminal_cwd(server_data['url'], headers, cookies)
tools_dict = {}
for spec in specs:

View file

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

View file

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

View file

@ -130,7 +130,7 @@ Your remediation guidance can include, for example:
> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. Deployments that do not need `workspace.tools` or Functions plugin execution can set `ENABLE_PLUGINS=false`. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
> [!IMPORTANT]
> **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. For more details, see our [Plugin Security documentation](https://docs.openwebui.com/features/extensibility/plugin/).

View file

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

View file

@ -6,12 +6,6 @@
font-display: swap;
}
@font-face {
font-family: 'Archivo';
src: url('/assets/fonts/Archivo-Variable.ttf');
font-display: swap;
}
@font-face {
font-family: 'Mona Sans';
src: url('/assets/fonts/Mona-Sans.woff2');
@ -74,6 +68,18 @@ code {
font-family: 'InstrumentSerif', sans-serif;
}
.app-muted {
@apply text-gray-500 dark:text-gray-400;
}
.app-icon-muted {
@apply text-gray-500 dark:text-gray-400;
}
.app-interactive-active {
@apply bg-gray-50/40 dark:bg-gray-800/40;
}
.marked a {
@apply underline;
}
@ -94,34 +100,43 @@ textarea::placeholder {
direction: auto;
}
textarea {
color-scheme: light;
}
.dark textarea {
color-scheme: dark;
}
textarea::-webkit-resizer,
textarea::-webkit-scrollbar-corner {
background: transparent;
}
.input-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
}
.input-prose-sm {
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
@apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
}
.markdown-prose {
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
}
.markdown-prose-sm {
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@apply underline;
}
.font-primary {
font-family: 'Archivo', 'Vazirmatn', sans-serif;
}
.drag-region {
-webkit-app-region: drag;
}
@ -673,7 +688,7 @@ input[type='number'] {
}
.tiptap th {
@apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
@apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-normal uppercase bg-gray-50 dark:bg-gray-850;
}
.tiptap td {

View file

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

View file

@ -311,6 +311,33 @@ export const putOrchestratorPolicy = async (
return res;
};
export const getOrchestratorPolicy = async (
token: string,
url: string,
key: string,
policyId: string,
authType: string = 'bearer'
): Promise<any> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/policy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
auth_type: authType,
policy_id: policyId
})
});
if (!res.ok) {
const body = await res.json();
throw Object.assign(new Error(body.detail || 'Failed to read policy'), { status: res.status });
}
return res.json();
};
export const putOrchestratorLifecycle = async (
token: string,
url: string,
@ -352,6 +379,35 @@ export const putOrchestratorLifecycle = async (
return res;
};
export const getOrchestratorLifecycle = async (
token: string,
url: string,
key: string,
policyId: string,
authType: string = 'bearer'
): Promise<any> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/lifecycle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
auth_type: authType,
policy_id: policyId
})
});
if (!res.ok) {
const body = await res.json();
throw Object.assign(new Error(body.detail || 'Failed to read lifecycle'), {
status: res.status
});
}
return res.json();
};
export const refreshOrchestratorTerminals = async (
token: string,
url: string,
@ -661,6 +717,31 @@ export const setModelsConfig = async (token: string, config: object) => {
return res;
};
export const getSubagentsConfig = async (token: string) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
});
if (!res.ok) throw await res.json();
return res.json();
};
export const setSubagentsConfig = async (token: string, config: object) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(config)
});
if (!res.ok) throw await res.json();
return res.json();
};
export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => {
let error = null;

View file

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

View file

@ -97,7 +97,8 @@ export const searchNotes = async (
viewOption: string | null = null,
permission: string | null = null,
sortKey: string | null = null,
page: number | null = null
page: number | null = null,
direction: string | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
@ -118,6 +119,10 @@ export const searchNotes = async (
searchParams.append('order_by', sortKey);
}
if (direction !== null) {
searchParams.append('direction', direction);
}
if (page !== null) {
searchParams.append('page', `${page}`);
}
@ -218,6 +223,98 @@ export const getNoteById = async (token: string, id: string) => {
return res;
};
export const getNoteChatById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
console.info('[note-chat] fetching linked chat', { noteId: id, url });
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
console.info('[note-chat] linked chat response', {
noteId: id,
status: res.status,
ok: res.ok
});
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error('[note-chat] linked chat request failed', { noteId: id, error: err });
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNoteChatsById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chats`;
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const createNoteChatById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
const res = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null;

View file

@ -0,0 +1,81 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type NotificationTarget = {
id: string;
type: 'webhook';
is_default?: boolean;
enabled: boolean;
events: string[];
delivery: 'away' | 'always';
config: {
url?: string;
url_masked?: string;
};
created_at?: number;
updated_at?: number;
};
export type NotificationEvent = {
event: string;
label: string;
description?: string;
};
const jsonRequest = async (url: string, token: string, method = 'GET', body?: object) => {
let error = null;
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
...(body ? { body: JSON.stringify(body) } : {})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNotificationEvents = async (token: string): Promise<NotificationEvent[]> => {
const data = await jsonRequest(`${WEBUI_API_BASE_URL}/notifications/events`, token);
return data?.events ?? data ?? [];
};
export const getNotificationTargets = async (
token: string
): Promise<{ targets: NotificationTarget[] }> =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token);
export const createNotificationTarget = async (
token: string,
target: Partial<NotificationTarget>
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target);
export const updateNotificationTarget = async (
token: string,
targetId: string,
target: Partial<NotificationTarget>
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'PUT', target);
export const deleteNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'DELETE');
export const setDefaultNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/default`, token, 'PUT');
export const testNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/test`, token, 'POST');

View file

@ -97,7 +97,9 @@ export const getSkillItems = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
page: number | null = null
page: number | null = null,
orderBy: string | null = null,
direction: string | null = null
) => {
let error = null;
@ -105,6 +107,8 @@ export const getSkillItems = async (
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (page) searchParams.append('page', page.toString());
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list?${searchParams.toString()}`, {
method: 'GET',

View file

@ -1,14 +0,0 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const checkActiveChats = async (token: string, chatIds: string[]) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ chat_ids: chatIds })
});
if (!res.ok) throw await res.json();
return res.json();
};

View file

@ -604,3 +604,87 @@ export const getUserPreview = async (token: string, userId: string) => {
return res;
};
export type UserUsageHeatmapEntry = {
date: string;
messages: number;
chats: number;
tokens: number;
models: Record<string, number>;
};
export type UserUsageResponse = {
totals: {
lifetime_tokens: number;
input_tokens: number;
output_tokens: number;
peak_daily_tokens: number;
longest_chat_seconds: number;
current_streak: number;
longest_streak: number;
total_chats: number;
active_days: number;
models_used: number;
messages: number;
user_messages: number;
assistant_messages: number;
};
heatmap: UserUsageHeatmapEntry[];
weekly_heatmap: UserUsageHeatmapEntry[];
cumulative_heatmap: UserUsageHeatmapEntry[];
insights: {
most_used_model: string | null;
average_tokens_per_chat: number;
average_messages_per_active_day: number;
user_message_share: number;
assistant_message_share: number;
};
top_models: Array<{
model_id: string;
messages: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}>;
top_tools: Array<{ name: string; count: number }>;
period: {
start_date: number;
end_date: number;
days: number;
};
};
export const getUserUsage = async (
token: string,
options: { days?: number; startDate?: number | null; endDate?: number | null } = {}
): Promise<UserUsageResponse | null> => {
let error = null;
const searchParams = new URLSearchParams();
if (options.days) searchParams.append('days', options.days.toString());
if (options.startDate) searchParams.append('start_date', options.startDate.toString());
if (options.endDate) searchParams.append('end_date', options.endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/users/usage?${searchParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -3,7 +3,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
@ -50,6 +49,7 @@
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
let headers = '';
let passthroughParams = '';
let tags = [];
@ -58,6 +58,18 @@
let loading = false;
let showDeleteConfirmDialog = false;
let showAdvanced = false;
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const parsePassthroughParams = (value: string) =>
value
.split(',')
.map((param) => param.trim())
.filter(Boolean);
const verifyOllamaHandler = async () => {
// remove trailing slash from url
@ -105,6 +117,7 @@
...(provider ? { provider } : {}),
...(azure ? { azure: true } : {}),
api_version: apiVersion,
passthrough_params: parsePassthroughParams(passthroughParams),
...(_headers ? { headers: _headers } : {})
}
},
@ -145,6 +158,7 @@
if (azure) {
if (!apiVersion) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('API Version is required'));
return;
@ -159,6 +173,7 @@
if (modelIds.length === 0) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
return;
}
@ -191,6 +206,7 @@
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
passthrough_params: parsePassthroughParams(passthroughParams),
...(provider ? { provider } : {}),
...(!ollama && azure ? { azure: true } : {}),
...(azure ? { api_version: apiVersion } : {}),
@ -207,6 +223,8 @@
key = '';
auth_type = 'bearer';
prefixId = '';
passthroughParams = '';
showAdvanced = false;
tags = [];
modelIds = [];
};
@ -224,6 +242,9 @@
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
passthroughParams = Array.isArray(connection.config?.passthrough_params)
? connection.config.passthrough_params.join(', ')
: (connection.config?.passthrough_params ?? '');
modelIds = connection.config?.model_ids ?? [];
if (ollama) {
@ -306,14 +327,13 @@
<label
for="url-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('URL')}</label
`}>{$i18n.t('URL')}</label
>
<div class="flex-1">
<input
id="url-input"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@ -373,9 +393,7 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="select-bearer-or-session" class={`text-xs text-gray-500`}
>{$i18n.t('Auth')}</label
>
@ -383,7 +401,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${selectClass}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
@ -407,27 +425,19 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
</div>
{/if}
@ -436,118 +446,12 @@
</div>
</div>
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-1">
<label
for="api-type-toggle"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('API Type')}</label
`}>{$i18n.t('API Type')}</label
>
<div>
@ -560,18 +464,7 @@
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if apiType === 'responses'}
<Tooltip
className="flex items-center gap-1"
content={$i18n.t(
'This feature is currently experimental and may not work as expected.'
)}
>
<span class=" text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
{$i18n.t('Responses')}
</Tooltip>
{$i18n.t('Responses')}
{:else}
{$i18n.t('Chat Completions')}
{/if}
@ -580,115 +473,268 @@
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
{$i18n.t('Model IDs')}
</div>
</div>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
{#if showAdvanced}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
</div>
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class="w-full py-1 text-sm rounded-lg bg-transparent {modelId
? ''
: 'text-gray-500'} {($settings?.highContrastMode ?? false)
? 'dark:placeholder:text-gray-100 placeholder:text-gray-700'
: 'placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden'}"
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
{#if !ollama && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="allowed-passthrough-params-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Passthrough params')}</label
>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="allowed-passthrough-params-input"
bind:value={passthroughParams}
placeholder={$i18n.t('thinking, output_config')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Model IDs')}
</div>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t(
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
{
url: url
}
)}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>
{/if}
</div>
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium">

View file

@ -7,7 +7,7 @@
</script>
<div class="px-3">
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
<div class="text-center dark:text-white text-2xl font-normal z-50" role="heading" aria-level="2">
{#if title}
{title}
{:else}

View file

@ -3,13 +3,11 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext<any>('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
@ -17,6 +15,8 @@
verifyTerminalServerConnection,
putOrchestratorPolicy,
putOrchestratorLifecycle,
getOrchestratorPolicy,
getOrchestratorLifecycle,
refreshOrchestratorTerminals
} from '$lib/apis/configs';
import { getTerminalConfig } from '$lib/apis/terminal';
@ -56,6 +56,13 @@
let lifecycleJson = '{}';
let refreshOnlyIdle = true;
let refreshReset = false;
let loadingPolicy = false;
let policyLoadError = '';
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const stringifyJson = (value: object | null | undefined) => {
return JSON.stringify(value && Object.keys(value).length ? value : {}, null, 2);
@ -73,10 +80,10 @@
accessGrants = connection?.config?.access_grants ?? [];
// Restore policy state
serverType = connection?.server_type ?? null;
serverType = connection?.server_type ?? (connection?.policy_id ? 'orchestrator' : null);
policyId = connection?.policy_id ?? '';
const p = connection?.policy ?? {};
const p: Record<string, any> = {};
policyImage = p.image ?? '';
policyIdleTimeout = p.idle_timeout_minutes ?? 30;
policyStorage = p.storage ? 'persistent' : 'ephemeral';
@ -89,9 +96,11 @@
// Restore resources
policyCpu = p.cpu_limit ?? '1';
policyMemory = p.memory_limit ?? '1Gi';
lifecycleJson = stringifyJson(connection?.lifecycle);
lifecycleJson = stringifyJson({});
refreshOnlyIdle = true;
refreshReset = false;
loadingPolicy = false;
policyLoadError = '';
} else {
id = '';
url = '';
@ -114,11 +123,53 @@
lifecycleJson = '{}';
refreshOnlyIdle = true;
refreshReset = false;
loadingPolicy = false;
policyLoadError = '';
}
};
const loadPolicy = async () => {
if (!connection || serverType !== 'orchestrator' || !policyId || direct) return;
loadingPolicy = true;
policyLoadError = '';
try {
let policy: any = null;
try {
policy = await getOrchestratorPolicy(localStorage.token, url, key, policyId, auth_type);
} catch (error: any) {
if (error?.status !== 404) throw error;
}
const lifecycle = await getOrchestratorLifecycle(
localStorage.token,
url,
key,
policyId,
auth_type
);
const data = policy?.data ?? {};
policyImage = data.image ?? '';
policyIdleTimeout = data.idle_timeout_minutes ?? 30;
policyStorage = data.storage ? 'persistent' : 'ephemeral';
policyStorageSize = data.storage ?? '5Gi';
policyEnvPairs = Object.entries(data.env ?? {}).map(([key, value]) => ({
key,
value: String(value)
}));
policyCpu = data.cpu_limit ?? '1';
policyMemory = data.memory_limit ?? '1Gi';
lifecycleJson = stringifyJson(lifecycle?.data);
} catch (error: any) {
policyLoadError = error?.message || String(error);
} finally {
loadingPolicy = false;
}
};
$: if (show) {
init();
void loadPolicy();
}
const verifyHandler = async () => {
@ -262,6 +313,14 @@
url = url.replace(/\/$/, '');
// Bearer key whitespace breaks the terminal WebSocket auth (HTTP headers strip it, JSON doesn't)
key = key.trim();
if (loadingPolicy) {
toast.error($i18n.t('Policy is still loading'));
return;
}
if (policyLoadError) {
toast.error($i18n.t('Failed to load policy: {{error}}', { error: policyLoadError }));
return;
}
// Save policy to orchestrator if applicable
let policyData = {};
@ -301,8 +360,7 @@
},
// Policy fields
...(serverType ? { server_type: serverType } : {}),
...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {}),
...(serverType === 'orchestrator' ? { policy: policyData, lifecycle: lifecycleData } : {})
...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {})
};
onSubmit(result);
@ -339,9 +397,7 @@
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="terminal-name" class={`text-xs text-gray-500`}
>{$i18n.t('Name')}</label
>
</div>
@ -349,7 +405,7 @@
<div class="flex flex-1 items-center">
<input
id="terminal-name"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={name}
placeholder={$i18n.t('My Terminal')}
@ -360,9 +416,7 @@
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="terminal-id" class={`text-xs text-gray-500`}
>{$i18n.t('ID')}
<span class="opacity-50">({$i18n.t('optional')})</span></label
>
@ -370,7 +424,7 @@
<div class="flex flex-1 items-center">
<input
id="terminal-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={id}
placeholder="auto"
@ -384,17 +438,13 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="terminal-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
<label for="terminal-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder="http://localhost:9900"
@ -459,16 +509,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Policy ID')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyId}
placeholder="python-ds"
@ -478,13 +526,18 @@
</div>
</div>
</div>
{#if loadingPolicy}
<div class="mt-2 text-xs text-gray-500">{$i18n.t('Loading policy...')}</div>
{:else if policyLoadError}
<div class="mt-2 text-xs text-red-600 dark:text-red-400">
{$i18n.t('Failed to load policy: {{error}}', { error: policyLoadError })}
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Image')}
<span class="opacity-50">({$i18n.t('optional')})</span>
</div>
@ -492,7 +545,7 @@
<div class="flex flex-1 items-center">
<input
id="policy-image"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyImage}
placeholder="ghcr.io/open-webui/open-terminal:latest"
@ -505,16 +558,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('CPU')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-cpu"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyCpu}
placeholder="1"
@ -524,16 +575,14 @@
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Memory')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-memory"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyMemory}
placeholder="1Gi"
@ -546,18 +595,13 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Storage')}
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={policyStorage}
>
<select class={`w-full text-sm ${selectClass}`} bind:value={policyStorage}>
<option value="ephemeral">{$i18n.t('Ephemeral')}</option>
<option value="persistent">{$i18n.t('Persistent')}</option>
</select>
@ -566,7 +610,7 @@
<div class="flex flex-1 items-center">
<input
id="policy-storage-size"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyStorageSize}
placeholder="5Gi"
@ -579,9 +623,7 @@
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Idle Timeout')}
<span class="opacity-50">({$i18n.t('min')})</span>
</div>
@ -589,7 +631,7 @@
<div class="flex flex-1 items-center">
<input
id="idle-timeout"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="number"
min="0"
bind:value={policyIdleTimeout}
@ -604,9 +646,7 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Environment Variables')}
</div>
<button
@ -621,13 +661,13 @@
{#each policyEnvPairs as pair, idx}
<div class="flex gap-1.5 mb-1">
<input
class={`flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={pair.key}
placeholder="KEY"
/>
<input
class={`flex-[2] text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`flex-[2] text-sm font-mono ${inputClass}`}
type="text"
bind:value={pair.value}
placeholder="value"
@ -648,15 +688,13 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Lifecycle JSON')}
</div>
</div>
<textarea
id="lifecycle-json"
class={`w-full min-h-24 resize-y text-xs bg-transparent font-mono rounded-lg p-2 border border-gray-200 dark:border-gray-800 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full min-h-24 resize-y text-xs font-mono ${inputClass}`}
bind:value={lifecycleJson}
spellcheck="false"
placeholder={`{\n "reset": {\n "schedule": "@weekly",\n "timezone": "UTC"\n }\n}`}
@ -675,6 +713,11 @@
<span>{$i18n.t('Reset persisted files')}</span>
</label>
</div>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t(
'Policy changes apply to newly provisioned terminals. Refresh matching terminals to apply them to existing terminals.'
)}
</div>
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition"
@ -708,19 +751,12 @@
</button>
{#if !direct}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
type="button"
<AccessButton
className="mt-2"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-xs font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
/>
{/if}
</div>
@ -729,9 +765,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
@ -744,7 +778,7 @@
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
id="openapi-path"
bind:value={path}
@ -756,9 +790,7 @@
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs mt-1 text-gray-500`}>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
@ -773,9 +805,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Auth')}
</div>
</div>
@ -783,10 +813,7 @@
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<select class={`w-full text-sm ${selectClass}`} bind:value={auth_type}>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !direct}
@ -804,21 +831,15 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
@ -843,8 +864,9 @@
</div>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 disabled:opacity-50 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="submit"
disabled={loadingPolicy || !!policyLoadError}
>
{$i18n.t('Save')}
</button>

View file

@ -8,7 +8,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@ -20,7 +19,7 @@
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from './common/Textarea.svelte';
@ -70,6 +69,11 @@
let showAccessControlModal = false;
let showDeleteConfirmDialog = false;
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const registerOAuthClientHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
@ -533,17 +537,13 @@
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
<label for="enter-name" class={`text-xs text-gray-500`}>{$i18n.t('Name')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="enter-name"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
@ -554,9 +554,7 @@
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="enter-id" class={`text-xs text-gray-500`}
>{$i18n.t('ID')}
{#if type !== 'mcp'}<span class="opacity-50">({$i18n.t('optional')})</span
>{/if}</label
@ -565,7 +563,7 @@
<div class="flex flex-1 items-center">
<input
id="enter-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={id}
placeholder="auto"
@ -578,16 +576,14 @@
</div>
<div class="flex flex-col w-full mt-1 mb-1.5">
<label
for="description"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="description" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
@ -599,17 +595,13 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
<label for="api-base-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="api-base-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@ -656,10 +648,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
{$i18n.t('Auth')}
</div>
</div>
@ -686,13 +675,13 @@
{#if !oauthClientInfo}
<div
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
class="text-xs font-normal px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
>
{$i18n.t('Not Registered')}
</div>
{:else}
<div
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
class="text-xs font-normal px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
>
{$i18n.t('Registered')}
</div>
@ -705,7 +694,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${selectClass}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
@ -731,26 +720,20 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if auth_type === 'oauth_2.1'}
<div
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
class={`flex items-center text-xs self-center translate-y-[1px] text-gray-500`}
>
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
</div>
@ -768,7 +751,7 @@
/>
<div class="flex flex-1 items-center">
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={oauthServerUrl}
placeholder={$i18n.t('OAuth Server URL')}
@ -804,19 +787,12 @@
</button>
{#if !direct}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
type="button"
<AccessButton
className="mt-2"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-xs font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
/>
{/if}
</div>
@ -826,10 +802,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
@ -839,7 +812,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${selectClass}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
@ -854,7 +827,7 @@
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
id="url-or-path"
bind:value={path}
@ -864,12 +837,10 @@
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs w-full self-center translate-y-[1px] text-gray-500`}>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
class={`w-full text-sm ${inputClass}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
@ -882,9 +853,7 @@
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs mt-1 text-gray-500`}>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
@ -899,16 +868,14 @@
{#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="oauth-scope"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="oauth-scope" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('OAuth Scopes')}</label
>
<div class="flex flex-1 items-center">
<input
id="oauth-scope"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={oauthScope}
placeholder={$i18n.t('Use discovered scopes')}
@ -920,16 +887,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="oauth-resource-parameter"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="oauth-resource-parameter" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('OAuth Resource Parameter')}</label
>
<div class="flex flex-1 items-center">
<select
id="oauth-resource-parameter"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${selectClass}`}
bind:value={oauthResourceParameter}
>
<option value="auto">{$i18n.t('Automatic')}</option>
@ -947,8 +912,7 @@
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
@ -975,16 +939,14 @@
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full mt-2">
<label
for="function-name-filter-list"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
<label for="function-name-filter-list" class={`mb-1 text-xs text-gray-500`}
>{$i18n.t('Function Name Filter List')}</label
>
<div class="flex-1">
<input
id="function-name-filter-list"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={functionNameFilterList}
placeholder={$i18n.t('Enter function name filter list (e.g. func1, !func2)')}
@ -999,14 +961,14 @@
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2 mt-1"
>
<span class="font-medium">
<span class="font-normal">
{$i18n.t('Warning')}:
</span>
{$i18n.t(
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
)}
<a class="font-medium underline" href="https://docs.openwebui.com/" target="_blank"
<a class="font-normal underline" href="https://docs.openwebui.com/" target="_blank"
>{$i18n.t('Read more →')}</a
>
</div>

View file

@ -110,9 +110,9 @@
<Modal size="md" bind:show>
<div>
<!-- Header -->
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class="flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<input
class="w-full text-lg font-medium bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
class="w-full text-sm font-medium bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={name}
placeholder={$i18n.t('Automation title')}
@ -122,7 +122,7 @@
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-5" />
<XMark className="size-4" />
</button>
</div>
@ -154,7 +154,7 @@
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
? 'cursor-not-allowed'
: ''}"
on:click={submitHandler}

View file

@ -1,8 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { onMount, getContext } from 'svelte';
import { Confetti } from 'svelte-confetti';
import { getContext } from 'svelte';
import { WEBUI_NAME, config, settings } from '$lib/stores';
@ -17,10 +16,26 @@
export let show = false;
let changelog = null;
type ChangelogEntry = {
raw?: string;
};
type ChangelogVersion = {
date: string;
[section: string]: string | ChangelogEntry[];
};
let changelog: Record<string, ChangelogVersion> | null = null;
let error = false;
const init = async () => {
changelog = await getChangelog();
if (changelog || error) {
return;
}
changelog = await getChangelog().catch(() => {
error = true;
return null;
});
};
const closeModal = async () => {
@ -33,75 +48,150 @@
$: if (show) {
init();
}
const formatDate = (date: string) => {
if (!date) {
return '';
}
const [year, month, day] = date.split('-');
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
const monthIndex = Number(month) - 1;
return year && monthIndex >= 0 && monthIndex < months.length && day
? `${months[monthIndex]} ${Number(day)}, ${year}`
: date;
};
const getSectionEntries = (version: string, section: string) => {
const entries = changelog?.[version]?.[section];
return Array.isArray(entries) ? entries : [];
};
</script>
<Modal bind:show size="xl">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<h2 class="text-xl font-medium m-0">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
</h2>
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
<XMark className={'size-5'} />
<Modal
bind:show
size="lg"
className="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden"
containerClassName="p-3"
>
<div class="flex max-h-[58vh] flex-col">
<div
class="flex shrink-0 items-start justify-between gap-4 px-4 pb-2.5 pt-3.5 dark:text-white text-black"
>
<div class="min-w-0">
<h2 class="m-0 truncate text-base font-normal">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
</h2>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{$i18n.t('Release Notes')}</span>
<span class="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-700"></span>
<span>v{WEBUI_VERSION}</span>
</div>
</div>
<button
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200"
on:click={closeModal}
aria-label={$i18n.t('Close')}
>
<XMark className={'size-4'} />
</button>
</div>
<div class="flex items-center mt-1">
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
<div class="text-sm dark:text-gray-200">
v{WEBUI_VERSION}
</div>
</div>
</div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
<div class="mb-3">
{#if changelog}
<div
class="min-h-0 flex-1 overflow-y-auto px-4 py-2 text-gray-700 scrollbar-hidden dark:text-gray-100"
>
{#if changelog}
<div class="space-y-4">
{#each Object.keys(changelog) as version}
<div class=" mb-3 pr-2">
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
v{version} - {changelog[version].date}
</h3>
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
<section class="pr-1">
<div class="mb-2">
<h3 class="m-0 text-sm font-normal text-gray-950 dark:text-white">v{version}</h3>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{formatDate(changelog[version].date)}
</div>
</div>
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="w-full">
<div class="mb-3 w-full">
<div
class="font-semibold uppercase text-xs {section === 'added'
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
class="mb-2 text-[0.6875rem] font-normal uppercase tracking-wide {section ===
'added'
? 'text-blue-600 dark:text-blue-300'
: section === 'fixed'
? 'bg-green-500/20 text-green-700 dark:text-green-200'
? 'text-green-600 dark:text-green-300'
: section === 'changed'
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
? 'text-yellow-700 dark:text-yellow-300'
: section === 'removed'
? 'bg-red-500/20 text-red-700 dark:text-red-200'
: ''} w-fit rounded-xl px-2 my-2.5"
? 'text-red-600 dark:text-red-300'
: 'text-gray-500 dark:text-gray-400'}"
>
{section}
</div>
<div class="my-2.5 px-1.5 markdown-prose-sm !list-none !w-full !max-w-none">
{#each changelog[version][section] as entry}
<div class="my-2">
{@html DOMPurify.sanitize(entry?.raw)}
<div class="space-y-2 text-[0.8125rem] leading-relaxed">
{#each getSectionEntries(version, section) as entry}
<div class="flex gap-2.5">
<span
class="mt-[0.6em] h-1 w-1 shrink-0 rounded-full bg-gray-300 dark:bg-gray-700"
></span>
<div
class="min-w-0 markdown-prose-sm !max-w-none !text-[0.8125rem] text-gray-600 dark:text-gray-300 [&_*]:!my-0 [&_b]:!font-normal [&_strong]:!font-normal"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html DOMPurify.sanitize(entry?.raw)}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/each}
{/if}
</div>
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('Could not load release notes.')}
</p>
<button
on:click={() => {
error = false;
init();
}}
class="text-sm font-normal text-gray-700 transition hover:text-black dark:text-gray-300 dark:hover:text-white"
>
{$i18n.t('Retry')}
</button>
</div>
{:else}
<div
class="flex items-center justify-center py-16 text-sm text-gray-400 dark:text-gray-500"
>
{$i18n.t('Loading release notes...')}
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<div class="flex shrink-0 justify-end px-4 pb-3.5 pt-1.5 text-sm">
<button
on:click={closeModal}
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
class="font-normal text-gray-600 transition hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
>
<span class="relative">{$i18n.t("Okay, Let's Go!")}</span>
</button>

View file

@ -63,16 +63,16 @@
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
<XMark className={'size-4'} />
</button>
</div>
@ -102,9 +102,9 @@
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<div class="flex justify-end pt-3 text-sm font-normal">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"

View file

@ -114,7 +114,7 @@
<div>
{#if title}
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
<div class=" text-[13px] font-normal mb-0.5 line-clamp-1">{title}</div>
{/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">

View file

@ -1,103 +1,116 @@
<script>
import { getContext, onMount } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import Marquee from './common/Marquee.svelte';
import SlideShow from './common/SlideShow.svelte';
import ArrowRightCircle from './icons/ArrowRightCircle.svelte';
export let show = true;
export let getStartedHandler = () => {};
function setLogoImage() {
const logo = document.getElementById('logo');
let videoElement;
let playOnInteractionRegistered = false;
if (logo) {
const isDarkMode = document.documentElement.classList.contains('dark');
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
darkImage.onload = () => {
logo.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
};
darkImage.onerror = () => {
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
};
}
function playBackgroundVideo() {
if (!videoElement) {
return;
}
videoElement.play().catch(() => {
if (playOnInteractionRegistered) {
return;
}
playOnInteractionRegistered = true;
const playOnInteraction = () => {
videoElement.play().catch(() => {});
document.removeEventListener('click', playOnInteraction);
document.removeEventListener('touchstart', playOnInteraction);
playOnInteractionRegistered = false;
};
document.addEventListener('click', playOnInteraction);
document.addEventListener('touchstart', playOnInteraction);
});
}
$: if (show) {
setLogoImage();
$: if (show && videoElement) {
playBackgroundVideo();
}
</script>
{#if show}
<div class="w-full h-screen max-h-[100dvh] text-white relative">
<div class="fixed m-10 z-50">
<div class="flex space-x-2">
<div class=" self-center">
<img
id="logo"
crossorigin="anonymous"
src="{WEBUI_BASE_URL}/static/favicon.png"
class=" w-6 rounded-full"
alt="logo"
/>
</div>
</div>
<div class="relative h-screen max-h-[100dvh] w-full overflow-hidden text-white">
<div class="fixed top-6 left-6 z-50 sm:top-10 sm:left-10">
<img
id="logo"
crossorigin="anonymous"
src="/static/favicon.png"
class="size-6 rounded-full"
alt="logo"
/>
</div>
<SlideShow duration={5000} />
<video
bind:this={videoElement}
class="absolute inset-0 h-full w-full object-cover"
src="/assets/welcome.mp4"
autoplay
muted
loop
playsinline
preload="auto"
poster="/assets/welcome.webp"
aria-hidden="true"
></video>
<div
class="w-full h-full absolute top-0 left-0 bg-linear-to-t from-20% from-black to-transparent"
></div>
<div class="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent"></div>
<div class="absolute inset-0 bg-linear-to-r from-black/50 via-black/10 to-transparent"></div>
<div class="w-full h-full absolute top-0 left-0 backdrop-blur-xs bg-black/50"></div>
<div class="relative z-10 flex h-screen max-h-[100dvh] w-full">
<div class="flex w-full flex-col justify-end px-6 pb-8 sm:px-10 sm:pb-10 lg:px-16 lg:pb-14">
<div class="max-w-3xl">
<div class="mb-4 text-[11px] font-medium tracking-[0.18em] uppercase opacity-35">
Open WebUI
</div>
<div class="relative bg-transparent w-full h-screen max-h-[100dvh] flex z-10">
<div class="flex flex-col justify-end w-full items-center pb-10 text-center">
<div class="text-5xl lg:text-7xl font-secondary">
<Marquee
duration={5000}
words={[
$i18n.t('Explore the cosmos'),
$i18n.t('Unlock mysteries'),
$i18n.t('Chart new frontiers'),
$i18n.t('Dive into knowledge'),
$i18n.t('Discover wonders'),
$i18n.t('Ignite curiosity'),
$i18n.t('Forge new paths'),
$i18n.t('Unravel secrets'),
$i18n.t('Pioneer insights'),
$i18n.t('Embark on adventures')
]}
/>
<h1 class="m-0 max-w-3xl text-2xl leading-[1.15] font-light tracking-tight lg:text-4xl">
{$i18n.t('Welcome to your AI home.')}
</h1>
<div class="mt-0.5">{$i18n.t(`wherever you are`)}</div>
</div>
<p class="mt-6 max-w-xl text-sm leading-relaxed font-light text-white/60 lg:text-base">
{$i18n.t(
'Run AI on your own terms. Connect any model, extend with code, and protect what matters without compromise. Your models, your data, your machine, wherever you open it.'
)}
</p>
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
<div class="mt-8 flex flex-col items-start gap-6 sm:flex-row sm:items-center sm:gap-7">
<button
aria-label={$i18n.t('Get started')}
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
class="group relative z-20 inline-flex min-w-40 items-center justify-center gap-2 bg-white px-8 py-3 text-sm font-normal text-black transition hover:bg-white/90 focus:ring-2 focus:ring-white/50 focus:outline-hidden"
on:click={() => {
getStartedHandler();
}}
>
<ArrowRightCircle className="size-6" aria-hidden="true" />
{$i18n.t('Get started')}
<svg
class="h-4 w-4 transition group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
{$i18n.t(`Get started`)}
</div>
<a
class="inline-flex items-center text-sm text-white/60 transition hover:text-white"
href="https://docs.openwebui.com/"
target="_blank"
rel="noopener noreferrer"
>
{$i18n.t('Read the docs')}
</a>
</div>
</div>
</div>

View file

@ -18,7 +18,7 @@
</script>
{#if loaded}
<div class="w-full h-full pb-2 px-[16px]">
<div class="w-full h-full pb-2">
<Dashboard />
</div>
{/if}

View file

@ -178,14 +178,18 @@
<Modal size="md" bind:show>
{#if model}
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<div class="text-lg font-medium self-center line-clamp-1">
<div class="text-sm font-medium self-center line-clamp-1">
{model.name}
</div>
</Tooltip>
<button class="self-center" on:click={close} aria-label="Close">
<XMark className={'size-5'} />
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={close}
aria-label="Close"
>
<XMark className={'size-4'} />
</button>
</div>
@ -193,7 +197,7 @@
<div class="px-5 border-b border-gray-100 dark:border-gray-850">
<div class="flex gap-4">
<button
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'overview'
class="py-2 text-sm font-normal border-b-2 transition-colors {selectedTab === 'overview'
? 'border-black dark:border-white text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => selectTab('overview')}
@ -202,7 +206,7 @@
</button>
{#if $config?.features?.enable_admin_chat_access}
<button
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'chats'
class="py-2 text-sm font-normal border-b-2 transition-colors {selectedTab === 'chats'
? 'border-black dark:border-white text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => selectTab('chats')}
@ -219,7 +223,7 @@
<div class="mb-4 mt-3">
<div class="flex items-center justify-between mb-2">
<Tooltip content={$i18n.t('Thumbs up/down ratings from users on model responses')}>
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide cursor-help">
<div class="text-xs text-gray-500 font-normal uppercase tracking-wide cursor-help">
{$i18n.t('Feedback Activity')}
</div>
</Tooltip>
@ -229,7 +233,7 @@
{#each TIME_RANGES as range}
<button
type="button"
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-normal {selectedRange ===
range.key
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
@ -249,14 +253,14 @@
<!-- Tags -->
<div class="mb-4">
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
<div class="text-xs text-gray-500 mb-2 font-normal uppercase tracking-wide">
{$i18n.t('Tags')}
</div>
{#if tags.length}
<div class="flex flex-wrap gap-1 -mx-1">
{#each tags as tagInfo}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
{tagInfo.tag} <span class="text-gray-500 font-normal">{tagInfo.count}</span>
</span>
{/each}
</div>
@ -283,7 +287,7 @@
<div class="flex justify-end pt-4">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>

View file

@ -230,13 +230,10 @@
}
</script>
<!-- Header with title and period selector -->
<div
class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="text-lg font-medium px-0.5 shrink-0">
<div class="flex items-center justify-between mb-2 gap-2">
<h2 class="text-sm font-medium text-gray-900 dark:text-white shrink-0">
{$i18n.t('Analytics')}
</div>
</h2>
<div class="flex items-center gap-2 flex-wrap justify-end min-w-0">
{#if groups.length > 0}
<select
@ -287,27 +284,27 @@
{#if !loading}
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
<span
><span class="font-medium text-gray-900 dark:text-gray-300"
><span class="font-normal text-gray-900 dark:text-gray-300"
>{summary.total_messages.toLocaleString()}</span
>
{$i18n.t('messages')}</span
>
<Tooltip content={$i18n.t('Token counts are estimates and may not reflect actual API usage')}>
<span class="cursor-help"
><span class="font-medium text-gray-900 dark:text-gray-300"
><span class="font-normal text-gray-900 dark:text-gray-300"
>{formatNumber(totalTokens.total)}</span
>
{$i18n.t('tokens')}</span
>
</Tooltip>
<span
><span class="font-medium text-gray-900 dark:text-gray-300"
><span class="font-normal text-gray-900 dark:text-gray-300"
>{summary.total_chats.toLocaleString()}</span
>
{$i18n.t('chats')}</span
>
<span
><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span>
><span class="font-normal text-gray-900 dark:text-gray-300">{summary.total_users}</span>
{$i18n.t('users')}</span
>
</div>
@ -328,7 +325,7 @@
]}
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
<div class="mb-4">
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
<div class="text-xs font-normal text-gray-600 dark:text-gray-400 mb-2 px-0.5">
{selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')}
</div>
<ChartLine
@ -350,7 +347,7 @@
<div class="grid md:grid-cols-2 gap-4">
<!-- Model Usage Table -->
<div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('Model Usage')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
@ -478,7 +475,7 @@
}}
>
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
@ -518,7 +515,7 @@
<!-- User Activity Table -->
<div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('User Activity')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
@ -586,7 +583,7 @@
{#each sortedUsers as user, idx (user.user_id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"

View file

@ -54,7 +54,7 @@
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
{$i18n.t('Model Usage')}
<span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
</div>
@ -137,7 +137,7 @@
<tr
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
>
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
@ -150,13 +150,13 @@
e.target.src = '/favicon.png';
}}
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
<span class="font-normal text-gray-800 dark:text-gray-200">{model.name}</span>
</div>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{model.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
{((model.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>

View file

@ -49,7 +49,7 @@
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
{$i18n.t('User Activity')}
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span>
</div>
@ -132,18 +132,18 @@
<tr
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
>
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
<span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs">
<span class="font-normal text-gray-800 dark:text-gray-200 font-mono text-xs">
{user.user_id.substring(0, 8)}...
</span>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{user.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
{((user.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>

View file

@ -1,7 +1,9 @@
<script>
import { getContext, tick, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getContext, onMount } from 'svelte';
import { page } from '$app/stores';
import { adminFeedbackCount, adminLeaderboardCount, models as _models } from '$lib/stores';
import { getFeedbackItems, getLeaderboard } from '$lib/apis/evaluations';
import { formatNumber } from '$lib/utils';
import Leaderboard from './Evaluations/Leaderboard.svelte';
import Feedbacks from './Evaluations/Feedbacks.svelte';
@ -28,8 +30,34 @@
};
let loaded = false;
$: formattedLeaderboardCount =
$adminLeaderboardCount === null ? null : formatNumber($adminLeaderboardCount);
$: formattedFeedbackCount =
$adminFeedbackCount === null ? null : formatNumber($adminFeedbackCount);
const getLeaderboardCount = (res) => {
const entries = res?.entries ?? [];
const modelMap = new Map(($_models ?? []).map((model) => [model.id, model]));
const activeModelCount = ($_models ?? []).filter(
(model) => model?.owned_by !== 'arena' && !model?.info?.meta?.hidden
).length;
const evaluatedModelCount = entries.filter((entry) => !modelMap.has(entry.model_id)).length;
return activeModelCount + evaluatedModelCount;
};
const loadCounts = async () => {
const [leaderboardRes, feedbackRes] = await Promise.all([
getLeaderboard(localStorage.token).catch(() => null),
getFeedbackItems(localStorage.token, 'updated_at', 'desc', 1).catch(() => null)
]);
adminLeaderboardCount.set(getLeaderboardCount(leaderboardRes));
adminFeedbackCount.set(feedbackRes?.total ?? null);
};
onMount(async () => {
await loadCounts();
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
@ -52,58 +80,40 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class="tabs mx-2 px-2 sm:mx-2.5 lg:mx-0 lg:px-2.5 flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-0 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
>
<a
id="leaderboard"
href="/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm6 5.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-1.5 0v-3.5Zm-2.75 1.5a.75.75 0 0 1 1.5 0v2a.75.75 0 0 1-1.5 0v-2Zm-2 .75a.75.75 0 0 0-.75.75v.5a.75.75 0 0 0 1.5 0v-.5a.75.75 0 0 0-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
<div class="self-center">{$i18n.t('Leaderboard')}</div>
{#if formattedLeaderboardCount !== null}
<div class="self-center text-sm opacity-60">
{formattedLeaderboardCount}
</div>
{/if}
</a>
<a
id="feedback"
href="/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'feedback'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M5.25 2A2.25 2.25 0 0 0 3 4.25v9a.75.75 0 0 0 1.183.613l1.692-1.195 1.692 1.195a.75.75 0 0 0 .866 0l1.692-1.195 1.693 1.195A.75.75 0 0 0 13 13.25v-9A2.25 2.25 0 0 0 10.75 2h-5.5Zm3.03 3.28a.75.75 0 0 0-1.06-1.06L4.97 6.47a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 0 0 1.06-1.06l-.97-.97h1.315c.76 0 1.375.616 1.375 1.375a.75.75 0 0 0 1.5 0A2.875 2.875 0 0 0 8.625 6.25H7.311l.97-.97Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Feedback')}</div>
<div class="self-center">{$i18n.t('Feedback')}</div>
{#if formattedFeedbackCount !== null}
<div class="self-center text-sm opacity-60">
{formattedFeedbackCount}
</div>
{/if}
</a>
</div>

View file

@ -5,6 +5,7 @@
const i18n = getContext('i18n');
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -17,19 +18,17 @@
</Tooltip>
<div slot="content">
<div
class="min-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<DropdownMenu className="min-w-[150px]">
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
dispatch('delete');
show = false;
}}
>
<GarbageBin strokeWidth="2" />
<GarbageBin className="size-3.5" strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</div>
</DropdownMenu>
</div>
</Dropdown>

View file

@ -42,12 +42,16 @@
<Modal size="sm" bind:show>
{#if selectedFeedback}
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class="text-sm font-medium self-center">
{$i18n.t('Feedback Details')}
</div>
<button class="self-center" on:click={close} aria-label="Close">
<XMark className={'size-5'} />
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={close}
aria-label="Close"
>
<XMark className={'size-4'} />
</button>
</div>
@ -131,7 +135,7 @@
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>

View file

@ -26,11 +26,12 @@
import FeedbackModal from './FeedbackModal.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { config } from '$lib/stores';
import { adminFeedbackCount, config } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import Select from '$lib/components/common/Select.svelte';
import Check from '$lib/components/icons/Check.svelte';
@ -89,6 +90,7 @@
if (res) {
items = res.items;
total = res.total;
adminFeedbackCount.set(total);
}
} catch (err) {
console.error(err);
@ -215,36 +217,68 @@
<Spinner className="size-5" />
</div>
{:else}
<div class="flex flex-col gap-1 mt-0.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Feedback History')}
<div>
{#if modelIds.length > 0 || total > 0}
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div
class="flex min-w-0 flex-1 bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
{#if modelIds.length > 0}
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={selectedModelId}
items={[
{ value: '', label: $i18n.t('All') },
...modelIds.map((mid) => ({ value: mid, label: mid }))
]}
placeholder={$i18n.t('All')}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
onChange={() => {
page = 1;
getFeedbacks();
}}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
{/if}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
{#if total > 0}
<Dropdown align="end">
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
class="flex h-8 shrink-0 items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 dark:text-gray-200 transition text-xs"
>
<div class="self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
{$i18n.t('Export')}
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
<div slot="content">
<div
class="w-[170px] rounded-2xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<DropdownMenu className="w-[170px]">
<button
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="select-none flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
type="button"
on:click={() => exportHandler('json')}
>
@ -252,75 +286,25 @@
</button>
<button
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="select-none flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
type="button"
on:click={() => exportHandler('csv')}
>
{$i18n.t('Export as CSV')}
</button>
</div>
</DropdownMenu>
</div>
</Dropdown>
{/if}
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
{#if modelIds.length > 0}
<div
class="px-2.5 flex w-full bg-transparent overflow-x-auto scrollbar-none mb-1"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={selectedModelId}
items={[
{ value: '', label: $i18n.t('All') },
...modelIds.map((mid) => ({ value: mid, label: mid }))
]}
placeholder={$i18n.t('All')}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
onChange={() => {
page = 1;
getFeedbacks();
}}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
</div>
{/if}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full px-2">
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (items ?? []).length === 0}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class="text-3xl mb-3">😕</div>
<div class="text-lg font-medium mb-1">{$i18n.t('No feedback found')}</div>
<div class="text-lg font-normal mb-1">{$i18n.t('No feedback found')}</div>
<div class="text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
@ -334,7 +318,7 @@
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
class="px-2.5 py-2 font-normal cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -357,7 +341,7 @@
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
class="px-2.5 py-2 font-normal cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
@ -380,7 +364,7 @@
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -403,7 +387,7 @@
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -424,7 +408,11 @@
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
<th
scope="col"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
>
</th>
</tr>
</thead>
<tbody class="">
@ -433,7 +421,7 @@
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition rounded-xl"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-medium">
<td class=" py-0.5 text-right font-normal">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
@ -453,7 +441,7 @@
{#if feedback.data?.sibling_model_ids}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
class="font-normal text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
>
{feedback.data?.model_id}
</div>
@ -477,7 +465,7 @@
{:else}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
class="text-sm font-normal text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
>
{feedback.data?.model_id}
</div>
@ -488,7 +476,7 @@
</td>
{#if feedback?.data?.rating}
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<td class="px-3 py-1 text-right font-normal text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback?.data?.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
@ -501,11 +489,11 @@
</td>
{/if}
<td class=" px-3 py-1 text-right font-medium">
<td class=" px-3 py-1 text-right font-normal">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1 text-right font-medium" on:click={(e) => e.stopPropagation()}>
<td class=" px-3 py-1 text-right font-normal" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);

View file

@ -1,11 +1,12 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getContext } from 'svelte';
import { adminLeaderboardCount, models } from '$lib/stores';
import { getLeaderboard } from '$lib/apis/evaluations';
import ModelModal from './LeaderboardModal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Search from '$lib/components/icons/Search.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -83,6 +84,7 @@
if (b.rating === '-') return -1;
return b.rating - a.rating;
});
adminLeaderboardCount.set(rankedModels.length);
} catch (err) {
console.error('Leaderboard load failed:', err);
}
@ -120,118 +122,133 @@
<ModelModal bind:show={showModal} model={selectedModel} onClose={closeModal} />
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
{$i18n.t('Leaderboard')}
<span class="text-lg text-gray-500">{rankedModels.length}</span>
</div>
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
<div class="flex flex-1">
<Search className="size-3 ml-1 mr-3 self-center" />
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
</Tooltip>
</div>
<div>
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900">
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div class="flex min-w-0 flex-1 items-center">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !rankedModels.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
{:else if rankedModels.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none {col.class}"
on:click={() => toggleSort(col.key)}
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
}}
>
<div
class="flex gap-1.5 items-center {col.class.includes('right') ? 'justify-end' : ''}"
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !rankedModels.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
{:else if rankedModels.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
<th
scope="col"
class="px-2.5 py-2 font-normal cursor-pointer select-none {col.class}"
on:click={() => toggleSort(col.key)}
>
{$i18n.t(col.label)}
{#if orderBy === col.key}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.id)}
<tr
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openModal(model)}
>
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
{model.rating !== '-' ? idx + 1 : '-'}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1"
>{model.name}</span
>
</Tooltip>
</div>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
{model.rating}
</td>
<td class="px-3 py-1.5 text-right font-medium text-green-500 w-10">
{#if model.stats.won === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
<div
class="flex gap-1.5 items-center {col.class.includes('right')
? 'justify-end'
: ''}"
>
<span class="group-hover:hidden">{model.stats.won}</span>
{/if}
</td>
<td class="px-3 py-1.5 text-right font-medium text-red-500 w-10">
{#if model.stats.lost === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.lost}</span>
{/if}
</td>
{$i18n.t(col.label)}
{#if orderBy === col.key}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</thead>
<tbody>
{#each sortedModels as model, idx (model.id)}
<tr
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openModal(model)}
>
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{model.rating !== '-' ? idx + 1 : '-'}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-normal text-gray-800 dark:text-gray-200 line-clamp-1"
>{model.name}</span
>
</Tooltip>
</div>
</td>
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{model.rating}
</td>
<td class="px-3 py-1.5 text-right font-normal text-green-500 w-10">
{#if model.stats.won === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.won}</span>
{/if}
</td>
<td class="px-3 py-1.5 text-right font-normal text-red-500 w-10">
{#if model.stats.lost === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.lost}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">

View file

@ -61,21 +61,25 @@
<Modal size="md" bind:show>
{#if model}
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<div class="text-lg font-medium self-center line-clamp-1">
<div class="text-sm font-medium self-center line-clamp-1">
{model.name}
</div>
</Tooltip>
<button class="self-center" on:click={close} aria-label="Close">
<XMark className={'size-5'} />
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={close}
aria-label="Close"
>
<XMark className={'size-4'} />
</button>
</div>
<div class="px-5 pb-4 dark:text-gray-200">
<!-- Activity Chart -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide">
<div class="text-xs text-gray-500 font-normal uppercase tracking-wide">
{$i18n.t('Activity')}
</div>
<div
@ -84,7 +88,7 @@
{#each TIME_RANGES as range}
<button
type="button"
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-normal {selectedRange ===
range.key
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
@ -103,14 +107,14 @@
</div>
<div class="mb-4">
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
<div class="text-xs text-gray-500 mb-2 font-normal uppercase tracking-wide">
{$i18n.t('Tags')}
</div>
{#if topTags.length}
<div class="flex flex-wrap gap-1 -mx-1">
{#each topTags as tagInfo}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
{tagInfo.tag} <span class="text-gray-500 font-normal">{tagInfo.count}</span>
</span>
{/each}
</div>
@ -121,7 +125,7 @@
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>

View file

@ -1,8 +1,12 @@
<script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -19,7 +23,6 @@
toggleGlobalById
} from '$lib/apis/functions';
import Download from '../icons/Download.svelte';
import Tooltip from '../common/Tooltip.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
import { getModels } from '$lib/apis';
@ -32,15 +35,16 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import XMark from '../icons/XMark.svelte';
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
import ImportModal from '../ImportModal.svelte';
import ViewSelector from '../workspace/common/ViewSelector.svelte';
import TagSelector from '../workspace/common/TagSelector.svelte';
import CommunityDiscover from '../workspace/common/CommunityDiscover.svelte';
import { capitalizeFirstLetter } from '$lib/utils';
import Spinner from '../common/Spinner.svelte';
import SplitCreateButton from '../common/SplitCreateButton.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
const i18n = getContext('i18n');
@ -51,6 +55,9 @@
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openFunctionMenuId: string | null = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
@ -78,7 +85,27 @@
}, 300);
};
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
const downloadFunctions = async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
};
$: if (
functions &&
selectedType !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
setFilteredItems();
}
@ -97,7 +124,32 @@
(viewOption === 'created' && f.user_id === $user?.id) ||
(viewOption === 'shared' && f.user_id !== $user?.id))
)
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
.sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1;
if (sortKey === 'name') {
return direction * (a.name ?? '').localeCompare(b.name ?? '');
}
return direction * ((a.updated_at ?? 0) - (b.updated_at ?? 0));
});
};
const setSortKey = (key: string) => {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
}
};
const openFunction = (func) => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
@ -268,8 +320,8 @@
/>
{#if loaded}
<div class="px-4.5 w-full">
<div class="flex flex-col gap-1 px-1 mt-2.5 mb-2">
<div class="px-2.5 w-full">
<div class="flex flex-col gap-1 px-1 mt-0.5">
<div class="flex justify-between items-center mb-1 w-full">
<input
id="documents-import-input"
@ -285,78 +337,53 @@
/>
<div class="flex justify-between items-center w-full">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div class="flex items-center md:self-center text-sm font-normal px-0.5 gap-1.5 shrink-0">
<div>
{$i18n.t('Functions')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
<div class="text-sm font-normal text-gray-500 dark:text-gray-500 opacity-60">
{filteredItems.length}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
{#if $user?.role === 'admin'}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={() => {
functionsImportInputElement.click();
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
{#if functions.length}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
{/if}
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
>
<div
class="cursor-pointer px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Function')}</div>
</div>
</AddFunctionMenu>
<div class="flex w-full justify-end">
<SplitCreateButton
actions={[
{
id: 'functions-new',
label: $i18n.t('Create'),
href: '/admin/functions/create'
},
{
id: 'functions-import-link',
label: $i18n.t('Import From Link'),
onClick: () => {
showImportModal = true;
}
},
{
id: 'functions-import',
label: $i18n.t('Import JSON'),
onClick: () => functionsImportInputElement?.click(),
visible: $user?.role === 'admin'
},
{
id: 'functions-export',
label: $i18n.t('Export JSON'),
onClick: downloadFunctions,
visible: $user?.role === 'admin' && functions.length > 0
}
]}
/>
</div>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1">
<div class="space-y-1">
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
@ -381,217 +408,316 @@
</div>
{/if}
</div>
</div>
<div
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<ViewSelector
bind:value={viewOption}
onChange={async (value) => {
localStorage.workspaceViewOption = value;
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}}
/>
await tick();
}}
/>
<TagSelector
bind:value={selectedType}
items={[
{ value: 'pipe', label: $i18n.t('Pipe') },
{ value: 'filter', label: $i18n.t('Filter') },
{ value: 'action', label: $i18n.t('Action') },
{ value: 'event', label: $i18n.t('Event') }
]}
/>
<TagSelector
bind:value={selectedType}
align="end"
items={[
{ value: 'pipe', label: $i18n.t('Pipe') },
{ value: 'filter', label: $i18n.t('Filter') },
{ value: 'action', label: $i18n.t('Action') },
{ value: 'event', label: $i18n.t('Event') }
]}
/>
</div>
</div>
</div>
{#if (filteredItems ?? []).length !== 0}
<div class="px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
{$i18n.t('Title')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as func (func.id)}
<div
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
role="button"
tabindex="0"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openFunction(func);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFunction(func);
}
}}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<Tooltip content={func.id} placement="top-start">
<div class=" flex items-center gap-1.5">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class="shrink-0 rounded-sm bg-gray-500/20 px-1 text-[10px] uppercase leading-4 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
<div class="line-clamp-1 text-sm">
{func.name}
</div>
<Tooltip content={func.id} className="min-w-0" placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{func.name}
</div>
</Tooltip>
{#if func?.meta?.manifest?.version}
<div class=" text-gray-500 text-xs font-medium shrink-0">
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
>
v{func?.meta?.manifest?.version ?? ''}
</div>
{/if}
</div>
</Tooltip>
<div class="flex gap-1.5 px-1">
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)
})}
<Tooltip content={dayjs(func.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs(func.updated_at * 1000).fromNow()}
</div>
</Tooltip>
</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
</div>
{#if func?.meta?.description}
<Tooltip
content={func?.meta?.description}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{func?.meta?.description}
</div>
</Tooltip>
{/if}
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteHandler(func);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<FunctionMenu
{func}
editHandler={() => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
}}
cloneHandler={() => {
cloneHandler(func);
}}
exportHandler={() => {
exportHandler(func);
}}
deleteHandler={async () => {
selectedFunction = func;
showDeleteConfirm = true;
}}
toggleGlobalHandler={() => {
if (['filter', 'action'].includes(func.type)) {
toggleGlobalHandler(func);
}
}}
onClose={() => {}}
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</FunctionMenu>
{/if}
<div class=" self-center mx-1">
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
on:change={async (e) => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
<div class="truncate">
{capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center gap-1.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(func);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Support')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart className="size-4" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Valves')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.940 1.11-.940h2.593c.55 0 1.02.398 1.11.940l.213 1.281c.063.374.313.686.645.870.074.040.147.083.220.127.325.196.720.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.370.490l1.296 2.247a1.125 1.125 0 0 1-.260 1.431l-1.003.827c-.293.241-.438.613-.430.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.750.430.991l1.004.827c.424.350.534.955.260 1.430l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.750-.072-1.076.124a6.47 6.47 0 0 1-.220.128c-.331.183-.581.495-.644.869l-.213 1.281c-.090.543-.560.940-1.110.940h-2.594c-.550 0-1.019-.398-1.110-.940l-.213-1.281c-.062-.374-.312-.686-.644-.870a6.52 6.52 0 0 1-.220-.127c-.325-.196-.720-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.490l-1.297-2.247a1.125 1.125 0 0 1 .260-1.431l1.004-.827c.292-.240.437-.613.430-.991a6.932 6.932 0 0 1 0-.255c.007-.380-.138-.751-.430-.992l-1.004-.827a1.125 1.125 0 0 1-.260-1.430l1.297-2.247a1.125 1.125 0 0 1 1.370-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.220-.128.332-.183.582-.495.644-.869l.214-1.280Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<FunctionMenu
{func}
show={openFunctionMenuId === func.id}
editHandler={() => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
}}
cloneHandler={() => {
cloneHandler(func);
}}
exportHandler={() => {
exportHandler(func);
}}
deleteHandler={async () => {
selectedFunction = func;
showDeleteConfirm = true;
}}
toggleGlobalHandler={() => {
if (['filter', 'action'].includes(func.type)) {
toggleGlobalHandler(func);
}
}}
onClose={() => {
openFunctionMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Function Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openFunctionMenuId = openFunctionMenuId === func.id ? null : func.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</FunctionMenu>
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
on:change={async () => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
</Tooltip>
</button>
{/if}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No functions found')}</div>
<div class=" text-gray-500 text-center text-xs">
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">{$i18n.t('No functions found')}</div>
<div class="text-center text-xs leading-5 text-gray-500">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
@ -606,30 +732,11 @@
</div> -->
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by Open WebUI Community')}
</div>
<a
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/functions"
target="_blank"
>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
<CommunityDiscover
href="https://openwebui.com/functions"
title={$i18n.t('Discover a function')}
description={$i18n.t('Discover, download, and explore custom functions')}
/>
{/if}
</div>
@ -641,7 +748,7 @@
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
{$i18n.t('This will delete')} <span class=" font-normal">{selectedFunction.name}</span>.
</div>
</DeleteConfirmDialog>

View file

@ -2,6 +2,7 @@
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Link from '$lib/components/icons/Link.svelte';
@ -30,34 +31,32 @@
</Tooltip>
<div slot="content">
<div
class="min-w-[190px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<DropdownMenu className="min-w-[190px]">
<button
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"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={async () => {
createHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<Pencil />
<div class="self-center">
<Pencil className="size-3.5" />
</div>
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
</button>
<button
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"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={async () => {
importFromLinkHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<Link />
<div class="self-center">
<Link className="size-3.5" />
</div>
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
</button>
</div>
</DropdownMenu>
</div>
</Dropdown>

View file

@ -7,7 +7,7 @@
import { extractFrontmatter, formatSkillName, 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';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
@ -15,7 +15,7 @@
let loading = false;
let showConfirm = false;
export let onSave = () => {};
export let onSave = /** @param {any} _value */ async (_value) => {};
export let edit = false;
export let clone = false;
@ -305,12 +305,16 @@ class Pipe:
const saveHandler = async () => {
loading = true;
onSave({
id,
name,
meta,
content
});
try {
await onSave({
id,
name,
meta,
content
});
} finally {
loading = false;
}
};
const submitHandler = async () => {
@ -333,146 +337,148 @@ class Pipe:
};
</script>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<form
bind:this={formElement}
class=" flex flex-col max-h-[100dvh] h-full"
on:submit|preventDefault={() => {
if (edit) {
submitHandler();
} else {
showConfirm = true;
}
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form
bind:this={formElement}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={() => {
if (edit) {
submitHandler();
} else {
showConfirm = true;
}
}}
>
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/admin/functions');
}}
>
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/admin/functions');
}}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-start gap-2 pb-2 px-1">
<div class="min-w-0 flex-1">
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
<input
class="w-full bg-transparent text-sm outline-hidden"
type="text"
placeholder={$i18n.t('Function Name')}
aria-label={$i18n.t('Function Name')}
bind:value={name}
required
/>
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
<div class="flex-1">
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary"
type="text"
placeholder={$i18n.t('Function Name')}
bind:value={name}
required
/>
</Tooltip>
</div>
<div class="flex items-center gap-2">
{#if !edit}
<select
class="text-xs bg-transparent border border-gray-100 dark:border-gray-800 rounded-lg px-2 py-1 outline-hidden"
bind:value={starterType}
on:change={(event) => selectStarterType(event.currentTarget.value)}
aria-label={$i18n.t('Function starter')}
>
<option value="filter">{$i18n.t('Filter')}</option>
<option value="event">{$i18n.t('Event')}</option>
</select>
{/if}
<Badge type="muted" content={$i18n.t('Function')} />
</div>
</div>
<div class=" flex gap-2 px-1 items-center">
{#if edit}
<div class="text-sm text-gray-500 shrink-0">
{id}
</div>
{:else}
<Tooltip className="w-full" content={$i18n.t('e.g. my_filter')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Function ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
{:else}
<Tooltip
className="w-full self-center items-center flex"
content={$i18n.t('e.g. A filter to remove profanity from text')}
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. my_filter')}
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-hidden"
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Function Description')}
bind:value={meta.description}
placeholder={$i18n.t('Function ID')}
aria-label={$i18n.t('Function ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
</div>
</div>
{/if}
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}
}
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
</div>
<div class="pb-3 flex justify-between">
<div class="flex-1 pr-3">
<div class="text-xs text-gray-500 line-clamp-2">
<span class=" font-semibold dark:text-gray-200">{$i18n.t('Warning:')}</span>
{$i18n.t('Functions allow arbitrary code execution.')} <br />—
<span class=" font-medium dark:text-gray-400"
>{$i18n.t(`don't install random functions from sources you don't trust.`)}</span
>
</div>
</div>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. A filter to remove profanity from text')}
placement="top-start"
>
{$i18n.t('Save')}
</button>
<input
class="w-full bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Function Description')}
aria-label={$i18n.t('Function Description')}
bind:value={meta.description}
required
/>
</Tooltip>
</div>
</div>
</form>
</div>
<div class="flex shrink-0 items-center gap-1">
{#if !edit}
<select
class="h-7 rounded-lg border border-gray-100 bg-transparent px-2 text-xs outline-hidden dark:border-gray-800"
bind:value={starterType}
on:change={(event) => selectStarterType(event.currentTarget.value)}
aria-label={$i18n.t('Function starter')}
>
<option value="filter">{$i18n.t('Filter')}</option>
<option value="event">{$i18n.t('Event')}</option>
</select>
{/if}
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg">
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
className="text-[11px]"
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}
}
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
</div>
<div class="shrink-0 py-2 text-xs text-gray-500">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<span class="font-normal dark:text-gray-200">{$i18n.t('Warning:')}</span>
{$i18n.t('Functions can execute arbitrary code.')}
<span class="font-normal dark:text-gray-400">
{$i18n.t('Only install functions from sources you trust.')}
</span>
</div>
<button
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
</div>
</div>
</form>
</div>
<ConfirmDialog

View file

@ -2,6 +2,7 @@
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte';
@ -23,7 +24,12 @@
export let onClose: Function;
let show = false;
export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script>
<Dropdown
@ -39,12 +45,10 @@
</Tooltip>
<div slot="content">
<div
class="min-w-[180px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
>
<DropdownMenu className="min-w-[180px]">
{#if ['filter', 'action'].includes(func.type)}
<div
class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
class="flex gap-2 justify-between items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer rounded-xl"
>
<div class="flex gap-2 items-center">
<GlobeAlt />
@ -60,9 +64,10 @@
{/if}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
editHandler();
closeMenu();
}}
>
<svg
@ -71,7 +76,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
class="size-3.5"
>
<path
stroke-linecap="round"
@ -84,9 +89,10 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
shareHandler();
closeMenu();
}}
>
<Share />
@ -94,9 +100,10 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
cloneHandler();
closeMenu();
}}
>
<DocumentDuplicate />
@ -104,9 +111,10 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
exportHandler();
closeMenu();
}}
>
<Download />
@ -116,14 +124,15 @@
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
deleteHandler();
closeMenu();
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</div>
</DropdownMenu>
</div>
</Dropdown>

View file

@ -20,13 +20,13 @@
import WebSearch from './Settings/WebSearch.svelte';
import Evaluations from './Settings/Evaluations.svelte';
import Analytics from './Analytics.svelte';
import CodeExecution from './Settings/CodeExecution.svelte';
import Integrations from './Settings/Integrations.svelte';
import Subagents from './Settings/Subagents.svelte';
import ChartBar from '../icons/ChartBar.svelte';
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Search from '../icons/Search.svelte';
import XMark from '../icons/XMark.svelte';
import AdminTabIcon from './Settings/AdminTabIcon.svelte';
const i18n = getContext('i18n');
@ -41,7 +41,9 @@
'authentication',
'connections',
'models',
'subagents',
'evaluations',
'analytics',
'integrations',
'documents',
'web',
@ -145,12 +147,24 @@
'export'
]
},
{
id: 'subagents',
title: 'Sub-agents',
route: '/admin/settings/subagents',
keywords: ['sub-agents', 'subagents', 'delegation', 'background', 'agents']
},
{
id: 'evaluations',
title: 'Evaluations',
route: '/admin/settings/evaluations',
keywords: ['evaluations', 'feedback', 'rating', 'arena', 'leaderboard', 'preference']
},
{
id: 'analytics',
title: 'Analytics',
route: '/admin/settings/analytics',
keywords: ['analytics', 'usage', 'stats', 'dashboard', 'models', 'users', 'messages']
},
{
id: 'integrations',
title: 'Integrations',
@ -283,6 +297,10 @@
const setFilteredSettings = () => {
filteredSettings = allSettings.filter((tab) => {
const searchTerm = search.toLowerCase().trim();
if (tab.id === 'analytics' && !($config?.features.enable_admin_analytics ?? true)) {
return false;
}
return (
search === '' ||
tab.title.toLowerCase().includes(searchTerm) ||
@ -301,6 +319,13 @@
}, 100);
};
const tabButtonClass = (active) =>
`flex items-center gap-1.5 h-7 px-2 lg:w-full shrink-0 rounded-lg text-xs text-left transition-colors duration-75 select-none ${
active
? 'font-medium text-gray-900 dark:text-white bg-gray-100 dark:bg-white/6'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`;
onMount(() => {
const containerElement = document.getElementById('admin-settings-tabs-container');
@ -319,321 +344,154 @@
});
</script>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
<div class="flex flex-col lg:flex-row w-full h-full min-h-0 pb-2">
<nav
id="admin-settings-tabs-container"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class="shrink-0 min-w-0 lg:min-h-0 flex lg:block border-b lg:border-b-0 lg:border-r border-gray-100/30 dark:border-white/[0.02] lg:w-[15rem]"
>
<div
class="hidden lg:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 -mx-1 mt-1.5"
id="settings-search"
class="tabs flex min-w-0 flex-1 overflow-x-auto lg:overflow-x-hidden lg:overflow-y-auto lg:flex-col p-1 lg:pt-4 gap-px"
>
<div class="self-center rounded-l-xl bg-transparent">
<Search className="size-3.5" strokeWidth="1.5" />
</div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input
class="w-full py-1 text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={search}
id="search-input-settings-modal"
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
/>
</div>
<span class="hidden lg:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mb-1">
{$i18n.t('Admin')}
</span>
<!-- {$i18n.t('General')} -->
<!-- {$i18n.t('Authentication')} -->
<!-- {$i18n.t('Connections')} -->
<!-- {$i18n.t('Models')} -->
<!-- {$i18n.t('Evaluations')} -->
<!-- {$i18n.t('Integrations')} -->
<!-- {$i18n.t('Documents')} -->
<!-- {$i18n.t('Web Search')} -->
<!-- {$i18n.t('Code Execution')} -->
<!-- {$i18n.t('Interface')} -->
<!-- {$i18n.t('Audio')} -->
<!-- {$i18n.t('Images')} -->
<!-- {$i18n.t('Pipelines')} -->
<!-- {$i18n.t('Database')} -->
{#each filteredSettings as tab (tab.id)}
<a
id={tab.id}
href={tab.route}
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition select-none {selectedTab ===
tab.id
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
<div
class="hidden lg:flex items-center gap-1.5 h-7 px-2 lg:w-full shrink-0 rounded-lg text-xs bg-gray-50/70 dark:bg-white/[0.03] mb-2"
id="settings-search"
>
<div class=" self-center mr-2">
{#if tab.id === 'general'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'authentication'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8 1.5A3.5 3.5 0 0 0 4.5 5v1H4a2 2 0 0 0-2 2v4.5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-.5V5A3.5 3.5 0 0 0 8 1.5ZM6 6V5a2 2 0 1 1 4 0v1H6Zm2 3a.75.75 0 0 0-.75.75v1a.75.75 0 0 0 1.5 0v-1A.75.75 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'connections'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M1 9.5A3.5 3.5 0 0 0 4.5 13H12a3 3 0 0 0 .917-5.857 2.503 2.503 0 0 0-3.198-3.019 3.5 3.5 0 0 0-6.628 2.171A3.5 3.5 0 0 0 1 9.5Z"
/>
</svg>
{:else if tab.id === 'models'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M10 1c3.866 0 7 1.79 7 4s-3.134 4-7 4-7-1.79-7-4 3.134-4 7-4zm5.694 8.13c.464-.264.91-.583 1.306-.952V10c0 2.21-3.134 4-7 4s-7-1.79-7-4V8.178c.396.37.842.688 1.306.953C5.838 10.006 7.854 10.5 10 10.5s4.162-.494 5.694-1.37zM3 13.179V15c0 2.21 3.134 4 7 4s7-1.79 7-4v-1.822c-.396.37-.842.688-1.306.953-1.532.875-3.548 1.369-5.694 1.369s-4.162-.494-5.694-1.37A7.009 7.009 0 013 13.179z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'evaluations'}
<DocumentChartBar />
{:else if tab.id === 'integrations'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'documents'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
>
<path d="M11.625 16.5a1.875 1.875 0 1 0 0-3.75 1.875 1.875 0 0 0 0 3.75Z" />
<path
fill-rule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875Zm6 16.5c.66 0 1.277-.19 1.797-.518l1.048 1.048a.75.75 0 0 0 1.06-1.06l-1.047-1.048A3.375 3.375 0 1 0 11.625 18Z"
clip-rule="evenodd"
/>
<path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
/>
</svg>
{:else if tab.id === 'web'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M21.721 12.752a9.711 9.711 0 0 0-.945-5.003 12.754 12.754 0 0 1-4.339 2.708 18.991 18.991 0 0 1-.214 4.772 17.165 17.165 0 0 0 5.498-2.477ZM14.634 15.55a17.324 17.324 0 0 0 .332-4.647c-.952.227-1.945.347-2.966.347-1.021 0-2.014-.12-2.966-.347a17.515 17.515 0 0 0 .332 4.647 17.385 17.385 0 0 0 5.268 0ZM9.772 17.119a18.963 18.963 0 0 0 4.456 0A17.182 17.182 0 0 1 12 21.724a17.18 17.18 0 0 1-2.228-4.605ZM7.777 15.23a18.87 18.87 0 0 1-.214-4.774 12.753 12.753 0 0 1-4.34-2.708 9.711 9.711 0 0 0-.944 5.004 17.165 17.165 0 0 0 5.498 2.477ZM21.356 14.752a9.765 9.765 0 0 1-7.478 6.817 18.64 18.64 0 0 0 1.988-4.718 18.627 18.627 0 0 0 5.49-2.098ZM2.644 14.752c1.682.971 3.53 1.688 5.49 2.099a18.64 18.64 0 0 0 1.988 4.718 9.765 9.765 0 0 1-7.478-6.816ZM13.878 2.43a9.755 9.755 0 0 1 6.116 3.986 11.267 11.267 0 0 1-3.746 2.504 18.63 18.63 0 0 0-2.37-6.49ZM12 2.276a17.152 17.152 0 0 1 2.805 7.121c-.897.23-1.837.353-2.805.353-.968 0-1.908-.122-2.805-.353A17.151 17.151 0 0 1 12 2.276ZM10.122 2.43a18.629 18.629 0 0 0-2.37 6.49 11.266 11.266 0 0 1-3.746-2.504 9.754 9.754 0 0 1 6.116-3.985Z"
/>
</svg>
{:else if tab.id === 'code-execution'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm2.22 1.97a.75.75 0 0 0 0 1.06l.97.97-.97.97a.75.75 0 1 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06l-1.5-1.5a.75.75 0 0 0-1.06 0ZM8.75 8.5a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'interface'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M2 4.25A2.25 2.25 0 0 1 4.25 2h7.5A2.25 2.25 0 0 1 14 4.25v5.5A2.25 2.25 0 0 1 11.75 12h-1.312c.1.128.21.248.328.36a.75.75 0 0 1 .234.545v.345a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.345a.75.75 0 0 1 .234-.545c.118-.111.228-.232.328-.36H4.25A2.25 2.25 0 0 1 2 9.75v-5.5Zm2.25-.75a.75.75 0 0 0-.75.75v4.5c0 .414.336.75.75.75h7.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-7.5Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'audio'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.557 2.066A.75.75 0 0 1 8 2.75v10.5a.75.75 0 0 1-1.248.56L3.59 11H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.59l3.162-2.81a.75.75 0 0 1 .805-.124ZM12.95 3.05a.75.75 0 1 0-1.06 1.06 5.5 5.5 0 0 1 0 7.78.75.75 0 1 0 1.06 1.06 7 7 0 0 0 0-9.9Z"
/>
<path
d="M10.828 5.172a.75.75 0 1 0-1.06 1.06 2.5 2.5 0 0 1 0 3.536.75.75 0 1 0 1.06 1.06 4 4 0 0 0 0-5.656Z"
/>
</svg>
{:else if tab.id === 'images'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm10.5 5.707a.5.5 0 0 0-.146-.353l-1-1a.5.5 0 0 0-.708 0L9.354 9.646a.5.5 0 0 1-.708 0L6.354 7.354a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0-.146.353V12a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V9.707ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'pipelines'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
/>
<path
d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
/>
<path
d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
/>
</svg>
{:else if tab.id === 'db'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M8 7c3.314 0 6-1.343 6-3s-2.686-3-6-3-6 1.343-6 3 2.686 3 6 3Z" />
<path
d="M8 8.5c1.84 0 3.579-.37 4.914-1.037A6.33 6.33 0 0 0 14 6.78V8c0 1.657-2.686 3-6 3S2 9.657 2 8V6.78c.346.273.72.5 1.087.683C4.42 8.131 6.16 8.5 8 8.5Z"
/>
<path
d="M8 12.5c1.84 0 3.579-.37 4.914-1.037.366-.183.74-.41 1.086-.684V12c0 1.657-2.686 3-6 3s-6-1.343-6-3v-1.22c.346.273.72.5 1.087.683C4.42 12.131 6.16 12.5 8 12.5Z"
/>
</svg>
{/if}
<div class="self-center rounded-l-xl bg-transparent">
<Search className="size-3.5" strokeWidth="1.5" />
</div>
<div class=" self-center">{$i18n.t(tab.title)}</div>
</a>
{/each}
</div>
<label class="sr-only" for="search-input-admin-settings">{$i18n.t('Search')}</label>
<input
class="w-full py-1 text-xs bg-transparent dark:text-gray-300 outline-hidden"
bind:value={search}
id="search-input-admin-settings"
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
/>
</div>
<div
class="flex-1 mt-3 lg:mt-1 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
>
{#if selectedTab === 'general'}
<General
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
<!-- {$i18n.t('General')} -->
<!-- {$i18n.t('Authentication')} -->
<!-- {$i18n.t('Connections')} -->
<!-- {$i18n.t('Models')} -->
<!-- {$i18n.t('Sub-agents')} -->
<!-- {$i18n.t('Evaluations')} -->
<!-- {$i18n.t('Analytics')} -->
<!-- {$i18n.t('Integrations')} -->
<!-- {$i18n.t('Documents')} -->
<!-- {$i18n.t('Web Search')} -->
<!-- {$i18n.t('Code Execution')} -->
<!-- {$i18n.t('Interface')} -->
<!-- {$i18n.t('Audio')} -->
<!-- {$i18n.t('Images')} -->
<!-- {$i18n.t('Pipelines')} -->
<!-- {$i18n.t('Database')} -->
{#each filteredSettings as tab (tab.id)}
<a
id={tab.id}
href={tab.route}
draggable="false"
class={tabButtonClass(selectedTab === tab.id)}
>
<AdminTabIcon id={tab.id} className="size-3.5" strokeWidth="2" />
<div class="self-center truncate">{$i18n.t(tab.title)}</div>
</a>
{/each}
</div>
</nav>
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'authentication'}
<Authentication />
{:else if selectedTab === 'connections'}
<Connections
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'models'}
<Models />
{:else if selectedTab === 'evaluations'}
<Evaluations />
{:else if selectedTab === 'integrations'}
<Integrations />
{:else if selectedTab === 'documents'}
<Documents
on:save={async () => {
toast.success($i18n.t('Settings saved successfully!'));
<div class="flex-1 min-h-0 p-4 lg:px-5 flex flex-col">
<div class="w-full h-full min-h-0 flex flex-col">
<div class="flex-1 min-h-0 overflow-hidden">
{#if selectedTab === 'general'}
<General
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'web'}
<WebSearch
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'authentication'}
<Authentication />
{:else if selectedTab === 'connections'}
<Connections
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'models'}
<Models />
{:else if selectedTab === 'subagents'}
<Subagents />
{:else if selectedTab === 'evaluations'}
<Evaluations />
{:else if selectedTab === 'analytics'}
<Analytics />
{:else if selectedTab === 'integrations'}
<Integrations />
{:else if selectedTab === 'documents'}
<Documents
on:save={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'code-execution'}
<CodeExecution
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'web'}
<WebSearch
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'interface'}
<Interface
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'audio'}
<Audio
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'images'}
<Images
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'db'}
<Database
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'pipelines'}
<Pipelines
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{/if}
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'code-execution'}
<CodeExecution
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'interface'}
<Interface
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'audio'}
<Audio
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'images'}
<Images
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'db'}
<Database
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'pipelines'}
<Pipelines
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{/if}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
export let label = '';
export let description = '';
export let forId = '';
export let className = '';
</script>
<div class={className}>
{#if label}
<label class="text-xs text-gray-600 dark:text-gray-400" for={forId || undefined}>
{label}
</label>
{/if}
<div class={label ? 'mt-1' : ''}>
<slot />
</div>
{#if description}
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{description}
</p>
{/if}
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
export let label = '';
export let description = '';
export let className = '';
export let labelClassName = '';
</script>
<div class="flex items-center justify-between gap-4 {className}">
<div class="min-w-0 text-xs text-gray-600 dark:text-gray-400 {labelClassName}">
<slot name="label">{label}</slot>
</div>
<div class="shrink-0">
<slot />
</div>
</div>
{#if description}
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{description}
</p>
{/if}

Some files were not shown because too many files have changed in this diff Show more