diff --git a/.env.example b/.env.example index 55bf5d386f..6f674e6e50 100644 --- a/.env.example +++ b/.env.example @@ -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='*' diff --git a/README.md b/README.md index bc504ce641..1342c8d838 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index f89738b0db..fceedd0a87 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -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: {{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, diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index cd97bda4a8..da99fbb94d 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -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' ) diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 71f9f15f1b..b627c69a18 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -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, diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 4a82cf7f26..1900ee752c 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -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) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 82eed2f4d6..1b9ed9cc53 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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'), diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 87a1e54608..2bcd45aea7 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -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 diff --git a/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py b/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py new file mode 100644 index 0000000000..bfbf2751bb --- /dev/null +++ b/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py @@ -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') diff --git a/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py new file mode 100644 index 0000000000..403a51a5ef --- /dev/null +++ b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py @@ -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') diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 5579bdbe95..048be9b270 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -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, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 52981d7e3f..08aed47b22 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -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}") diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 93a55f690d..5d0b2266b7 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -48,6 +48,7 @@ API_CONFIG_FIELDS = ( 'azure', 'api_version', 'extra_params', + 'passthrough_params', ) diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 8f0f7e0d1b..c9f9a3c55f 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -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: diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index f6650e2258..d48586a9ca 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -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), } ) ) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 9acd0c9b70..ff72dcfc65 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -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) diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 1cddd2f8fa..55e19e7aaf 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -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) diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 5bc8b54efc..522e1304c3 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -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())) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2788d4dd0c..2a6ac5c3e1 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -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}') diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index 6549c58c62..f2f0b92817 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -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 diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 459a238ba1..6b891bdadf 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -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 diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 98621ff80e..8747c65a4f 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -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, diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 533c1bd27f..d93139396e 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -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 diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 33e7dc1220..aaebedc26c 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -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}') diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index f048bbbf75..e3ebbfd96f 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -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 ############################ diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 8917bb37f0..93d2879c18 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -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) diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 78247c0b7e..d26734da64 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -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 = { diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 1c6369fb04..517d3e76e3 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -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, diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 477558e423..ceb47708f5 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -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}', ) diff --git a/backend/open_webui/routers/notifications.py b/backend/open_webui/routers/notifications.py new file mode 100644 index 0000000000..c3ae3ff3b7 --- /dev/null +++ b/backend/open_webui/routers/notifications.py @@ -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)) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 956e310088..eec46d6bc8 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -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, diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 66e1932318..ba94d11a65 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -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), diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 9af2d81573..4fefa2cb7a 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -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) diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 7cc7449b4b..4e377e2b78 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -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) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index ce2efa7096..1d89b4754c 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -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)}' diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index c830753b5a..b8ef51a280 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -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: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 378b682357..cff598b236 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -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 ############################ diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1cdd064b3a..25792b0e78 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -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: diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png deleted file mode 100644 index 08627a23f7..0000000000 Binary files a/backend/open_webui/static/favicon-dark.png and /dev/null differ diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 6475e5a239..d6b2c00419 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -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 diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f6631691d3..1d1bf8572d 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -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 # ============================================================================= diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index 0ec1cc8983..44d092e792 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -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) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index a98f95f97e..d0345ccdd5 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -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 diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py index eb5a84d0d4..1249dd60cb 100644 --- a/backend/open_webui/utils/actions.py +++ b/backend/open_webui/utils/actions.py @@ -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 '' or '.'; + # 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'], diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 9e8bc54cf0..52dd2ea36a 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -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']: diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 1ed539fefa..7189bfcad8 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -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] = {} diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index c95e23bb85..42b1aea9bd 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -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) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index d4ac9c5207..3d317585ea 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -7,9 +7,11 @@ Follows the utils/.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( diff --git a/backend/open_webui/utils/chat_fork.py b/backend/open_webui/utils/chat_fork.py new file mode 100644 index 0000000000..4963af0609 --- /dev/null +++ b/backend/open_webui/utils/chat_fork.py @@ -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 diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 54e0c3a26f..2349b23514 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -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( diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 84aebdaacb..f73278e7e2 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -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: diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 5c99d07e5a..3de90872e7 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -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 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 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: diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 39e139b053..9421975dcf 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -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: diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py new file mode 100644 index 0000000000..0ad94a6377 --- /dev/null +++ b/backend/open_webui/utils/notifications.py @@ -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) diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 53531c4350..4e3c6e5423 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -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() diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index 713b33cc6d..c6cbbce2ba 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -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]: diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py new file mode 100644 index 0000000000..e680068458 --- /dev/null +++ b/backend/open_webui/utils/subagents.py @@ -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.' diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py new file mode 100644 index 0000000000..e97348a8d5 --- /dev/null +++ b/backend/open_webui/utils/terminals.py @@ -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 diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py new file mode 100644 index 0000000000..25d98f17be --- /dev/null +++ b/backend/open_webui/utils/timers.py @@ -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() diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 6c33882c50..c78b315304 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -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: diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index d5646443e6..7ccc3f7261 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -33,6 +33,7 @@ alembic==1.18.4 pycrdt==0.13.1 redis +hiredis APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/backend/requirements.txt b/backend/requirements.txt index e33bf131ea..4a3169762f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index aced4883c9..ebe22b47c1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. **Granting a user the ability to create Tools is equivalent to giving them shell access to the server**. 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. **Granting a user the ability to create Tools is equivalent to giving them shell access to the server**. 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/). diff --git a/pyproject.toml b/pyproject.toml index 468543e71c..1944904e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/app.css b/src/app.css index 9352177bd8..1849577a8d 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 577b31cf49..64d2960dbf 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -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; diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 93df4464dc..edb80028ed 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -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 => { + 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 => { + 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; diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index b79e588947..d82f591289 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -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(); diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 80f0413bbc..e03d935f9c 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -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; diff --git a/src/lib/apis/notifications/index.ts b/src/lib/apis/notifications/index.ts new file mode 100644 index 0000000000..3b427b33fe --- /dev/null +++ b/src/lib/apis/notifications/index.ts @@ -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 => { + 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 +) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target); + +export const updateNotificationTarget = async ( + token: string, + targetId: string, + target: Partial +) => 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'); diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index 24139fa042..fc1dc24ce1 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -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', diff --git a/src/lib/apis/tasks/index.ts b/src/lib/apis/tasks/index.ts deleted file mode 100644 index dab6090fde..0000000000 --- a/src/lib/apis/tasks/index.ts +++ /dev/null @@ -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(); -}; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index fb68ab8f61..95c0ebac18 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -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; +}; + +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 => { + 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; +}; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 26b1190465..6715c80b1a 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -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 @@ {$i18n.t('URL')}
- @@ -383,7 +401,7 @@
+

+ {$i18n.t('Leave empty for the built-in default.')} +

+
+ {/if} +
+ {/if} +
+ + {#if !loading} +
+ +
+ {/if} + diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index c92572c3a0..dbc98576ec 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -8,8 +8,12 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Textarea from '$lib/components/common/Textarea.svelte'; + import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; + import AdminSettingField from './AdminSettingField.svelte'; + import AdminSettingRow from './AdminSettingRow.svelte'; + import AdminSettingSection from './AdminSettingSection.svelte'; - const i18n = getContext('i18n'); + const i18n: any = getContext('i18n'); export let saveHandler: Function; @@ -46,7 +50,11 @@ ]; let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'microsoft_web_iq', 'external']; - let webConfig = null; + let webConfig: any = null; + const inputClass = + 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; + const textareaClass = + 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const submitHandler = async () => { // Convert domain filter string to array before sending @@ -55,8 +63,8 @@ webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST ) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.split(',') - .map((domain) => domain.trim()) - .filter((domain) => domain.length > 0); + .map((domain: string) => domain.trim()) + .filter((domain: string) => domain.length > 0); } else if (!Array.isArray(webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST)) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = []; } @@ -67,8 +75,8 @@ webConfig.YOUTUBE_LOADER_LANGUAGE ) { webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.split(',') - .map((lang) => lang.trim()) - .filter((lang) => lang.length > 0); + .map((lang: string) => lang.trim()) + .filter((lang: string) => lang.length > 0); } else if (!Array.isArray(webConfig.YOUTUBE_LOADER_LANGUAGE)) { webConfig.YOUTUBE_LOADER_LANGUAGE = []; } @@ -144,1057 +152,496 @@
{ await submitHandler(); saveHandler(); }} > -
+

{$i18n.t('Web Search')}

+ +
{#if webConfig} -
-
-
{$i18n.t('General')}
- -
- -
-
- {$i18n.t('Web Search')} -
-
- -
-
- -
-
- {$i18n.t('Web Search Confirmation')} -
-
- - - -
-
- - {#if webConfig.ENABLE_WEB_SEARCH_CONFIRMATION} -
-
- {$i18n.t('Web Search Confirmation Content')} -
- {/if} -
+
- {#if !edit} + {#if preview && message.timestamp} +
+ + + +
+ {:else if !edit}
{#if message.done || siblings.length > 1} {#if siblings.length > 1} @@ -939,7 +943,7 @@ {#if messageIndexEdit}
/{siblings.length}
{:else}
{ messageIndexEdit = true; @@ -1069,6 +1073,53 @@ + {#if message.done && !readOnly && forkHandler} + + + + {/if} + + {#if onInsertToNote && visibleResponseContent} + + + + {/if} + {#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))} {:else} @@ -1496,6 +1548,21 @@ {/each} + + {#if message.timestamp} + + + + {/if} {/if} {/if} {/if} @@ -1514,7 +1581,7 @@ {/if} {#if (isLastMessage || ($settings?.keepFollowUpPrompts ?? false)) && message.done && !readOnly && (message?.followUps ?? []).length > 0} -
+
{ diff --git a/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte b/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte index 5a139e7047..6449d8c2f2 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte @@ -9,7 +9,7 @@
-
+
{$i18n.t('Follow up')}
@@ -17,7 +17,7 @@ {#each followUps as followUp, idx (idx)} -
+
diff --git a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte index bb65bf331b..80b25567f4 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte @@ -29,7 +29,7 @@ {#if history && history.length > 0} {#if status?.hidden !== true} -
+
+ {#if expanded} +
+ {content} +
+ {/if} +
diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 8d540828e7..21cb2f44b2 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -1,11 +1,14 @@ -
- {#each selectedModels as selectedModel, selectedModelIdx} -
-
-
- ({ - value: model.id, - label: model.name, - model: model - }))} - {pinModelHandler} - bind:value={selectedModel} - /> -
+
+
+
+
+ ({ + value: model.id, + label: model.name, + model: model + }))} + {pinModelHandler} + {className} + {triggerClassName} + {placement} + {align} + {showSetDefault} + onSetDefault={saveDefaultModel} + multipleEnabled={$user?.role === 'admin' || + ($user?.permissions?.chat?.multiple_models ?? true)} + {disabled} + bind:compareEnabled={compareModels} + bind:values={selectedModels} + />
- - {#if $user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true)} - {#if selectedModelIdx === 0} -
- - - -
- {:else} -
- - - -
- {/if} - {/if}
- {/each} -
- -{#if showSetDefault} -
-
-{/if} +
diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index f143ebc38a..e19163df4c 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -23,6 +23,8 @@ export let item: any = {}; export let index: number = -1; export let value: string | null = ''; + export let selectedValues: string[] = []; + export let compareEnabled = false; export let unloadModelHandler: (modelValue: string) => void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; @@ -43,23 +45,24 @@ }; let showMenu = false; + $: isSelected = compareEnabled ? selectedValues.includes(item.value) : value === item.value; @@ -70,7 +69,7 @@ {#if $user?.role === 'admin' && model?.owned_by === 'ollama'} {#if $config?.features.enable_community_sharing} -
+
{/if} -
+
diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index d7645cc2b6..46f556e870 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -11,7 +11,6 @@ import { flyAndScale } from '$lib/utils/transitions'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; - import { goto } from '$app/navigation'; import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; import { unloadModel } from '$lib/apis'; @@ -20,10 +19,10 @@ user, MODEL_DOWNLOAD_POOL, models, - mobile, temporaryChatEnabled, settings, - config + config, + showSettings } from '$lib/stores'; import { toast } from 'svelte-sonner'; import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils'; @@ -35,6 +34,8 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte'; + import Keyframes from '$lib/components/icons/Keyframes.svelte'; + import TagSelector from '$lib/components/workspace/common/TagSelector.svelte'; import ModelItem from './ModelItem.svelte'; @@ -43,6 +44,10 @@ export let id = ''; export let value: string | null = ''; + export let values: string[] | null = null; + export let compareEnabled = false; + export let multipleEnabled = false; + export let disabled = false; export let placeholder = $i18n.t('Select a model'); export let searchEnabled = true; export let searchPlaceholder = $i18n.t('Search a model'); @@ -57,17 +62,22 @@ [key: string]: any; }[] = []; - export let className = 'w-[32rem]'; + export let className = 'w-[20rem]'; export let triggerClassName = 'text-lg'; + export let placement: 'top' | 'bottom' | 'auto' = 'bottom'; + export let align: 'start' | 'end' = 'start'; + export let showSetDefault = false; + export let onSetDefault: () => Promise | void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; - let tagsContainerElement; - let show = false; let triggerElement: HTMLElement | null = null; let contentElement: HTMLElement | null = null; - let dropdownPosition = { top: 0, left: 0, width: 0 }; + let panelElement: HTMLElement | null = null; + let dropdownPosition = { top: 0, left: 0, maxHeight: undefined as number | undefined }; + let positionFrame: number | undefined; + let settleTimers: number[] = []; const portal = (node: HTMLElement) => { document.body.appendChild(node); @@ -78,23 +88,99 @@ }; }; - const updatePosition = () => { - if (!show || !triggerElement) return; - const rect = triggerElement.getBoundingClientRect(); - dropdownPosition = { - top: rect.bottom + 2, - left: $mobile ? 8 : rect.left, - width: $mobile ? window.innerWidth - 16 : 0 + const measureContent = () => { + if (!contentElement) return { width: 0, height: 0 }; + + const previousMaxHeight = panelElement?.style.maxHeight; + if (panelElement) panelElement.style.maxHeight = ''; + const rect = contentElement.getBoundingClientRect(); + if (panelElement && previousMaxHeight !== undefined) { + panelElement.style.maxHeight = previousMaxHeight; + } + + return { width: rect.width, height: rect.height }; + }; + + const visualViewportRect = () => { + const viewport = window.visualViewport; + return { + left: viewport?.offsetLeft ?? 0, + top: viewport?.offsetTop ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight }; }; - const toggleOpen = () => { + const updatePosition = () => { + if (!show || !triggerElement) return; + const rect = triggerElement.getBoundingClientRect(); + const { width: contentWidth, height: contentHeight } = measureContent(); + const viewport = visualViewportRect(); + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; + const pad = 8; + const gap = 2; + const spaceBelow = viewportBottom - rect.bottom - gap - pad; + const spaceAbove = rect.top - viewport.top - gap - pad; + const preferredLeft = align === 'end' && contentWidth ? rect.right - contentWidth : rect.left; + const maxLeft = contentWidth ? viewportRight - contentWidth - pad : preferredLeft; + const resolvedPlacement = + placement === 'auto' + ? contentHeight && spaceBelow < contentHeight && spaceAbove > spaceBelow + ? 'top' + : 'bottom' + : placement; + const availableHeight = resolvedPlacement === 'top' ? spaceAbove : spaceBelow; + const constrainedHeight = + contentHeight && availableHeight >= 0 + ? Math.min(contentHeight, availableHeight) + : contentHeight; + const top = + resolvedPlacement === 'top' && contentHeight + ? rect.top - constrainedHeight - gap + : rect.bottom + gap; + + dropdownPosition = { + top: Math.max(viewport.top + pad, Math.min(top, viewportBottom - pad - constrainedHeight)), + left: Math.max(viewport.left + pad, Math.min(preferredLeft, maxLeft)), + maxHeight: + contentHeight && availableHeight >= 0 && contentHeight > availableHeight + ? Math.max(0, availableHeight) + : undefined + }; + }; + + const schedulePositionUpdate = () => { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined; + updatePosition(); + }); + }; + + const scheduleSettledPositionUpdates = () => { + for (const timer of settleTimers) window.clearTimeout(timer); + settleTimers = []; + schedulePositionUpdate(); + for (const delay of [50, 150, 300]) { + settleTimers.push(window.setTimeout(schedulePositionUpdate, delay)); + } + }; + + const handleScroll = (event: Event) => { + if (event.target instanceof Node && contentElement?.contains(event.target)) return; + schedulePositionUpdate(); + }; + + const toggleOpen = async () => { show = !show; if (show) { searchValue = ''; listScrollTop = 0; resetView(); updatePosition(); + await tick(); + updatePosition(); window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); } else { document.getElementById(`model-selector-${id}-button`)?.blur(); @@ -106,7 +192,8 @@ const target = e.target as Node; if ( (triggerElement && triggerElement.contains(target)) || - (contentElement && contentElement.contains(target)) + (contentElement && contentElement.contains(target)) || + ((target as HTMLElement).closest?.('.model-selector-child-menu') ?? false) ) { return; } @@ -126,12 +213,22 @@ let tags = []; let selectedModel = ''; - $: selectedModel = items.find((item) => item.value === value) ?? ''; + $: selectedValues = values ?? (value ? [value] : []); + $: primaryValue = selectedValues[0] ?? value ?? ''; + $: selectedModel = items.find((item) => item.value === primaryValue) ?? ''; + $: selectedCount = selectedValues.filter(Boolean).length; + $: triggerLabel = selectedModel + ? compareEnabled && selectedCount > 1 + ? `${selectedModel.label} +${selectedCount - 1}` + : selectedModel.label + : placeholder; let searchValue = ''; let selectedTag = ''; let selectedConnectionType = ''; + let selectedFilter = ''; + let modelFilterItems = []; let ollamaVersion = null; let selectedModelIdx = 0; @@ -229,10 +326,42 @@ resetView(); } + $: modelFilterItems = [ + ...(items.find((item) => item.model?.connection_type === 'local') + ? [{ value: 'connection:local', label: $i18n.t('Local') }] + : []), + ...(items.find((item) => item.model?.connection_type === 'external') + ? [{ value: 'connection:external', label: $i18n.t('External') }] + : []), + ...(items.find((item) => item.model?.direct) + ? [{ value: 'connection:direct', label: $i18n.t('Direct') }] + : []), + ...tags.map((tag) => ({ value: `tag:${tag}`, label: tag })) + ]; + + $: selectedFilter = selectedConnectionType + ? `connection:${selectedConnectionType}` + : selectedTag + ? `tag:${selectedTag}` + : ''; + + const setModelFilter = (filterValue: string) => { + if (!filterValue) { + selectedConnectionType = ''; + selectedTag = ''; + } else if (filterValue.startsWith('connection:')) { + selectedConnectionType = filterValue.replace('connection:', ''); + selectedTag = ''; + } else if (filterValue.startsWith('tag:')) { + selectedConnectionType = ''; + selectedTag = filterValue.replace('tag:', ''); + } + }; + const resetView = async () => { await tick(); - const selectedInFiltered = filteredItems.findIndex((item) => item.value === value); + const selectedInFiltered = filteredItems.findIndex((item) => item.value === primaryValue); if (selectedInFiltered >= 0) { // The selected model is visible in the current filter @@ -255,6 +384,46 @@ await tick(); const item = document.querySelector(`[data-arrow-selected="true"]`); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + schedulePositionUpdate(); + }; + + const setCompareEnabled = (enabled: boolean) => { + compareEnabled = enabled; + + if (!enabled && values) { + values = [primaryValue || selectedValues[0] || '']; + value = values[0]; + } + }; + + const selectItem = (item, index: number) => { + selectedModelIdx = index; + + if (values) { + if (compareEnabled) { + const nextValues = selectedValues.includes(item.value) + ? selectedValues.length > 1 + ? selectedValues.filter((selectedValue) => selectedValue !== item.value) + : selectedValues + : [...selectedValues.filter(Boolean), item.value]; + + values = nextValues.length ? nextValues : [item.value]; + value = values[0]; + return; + } + + values = [item.value]; + value = item.value; + show = false; + return; + } + + value = item.value; + show = false; + }; + + const setDefaultHandler = async () => { + await onSetDefault(); }; const pullModelHandler = async () => { @@ -389,7 +558,7 @@ ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); }; - onMount(async () => { + onMount(() => { if (items) { tags = items .filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false)) @@ -398,6 +567,18 @@ // Remove duplicates and sort tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b)); } + + window.addEventListener('scroll', handleScroll, true); + window.visualViewport?.addEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate); + + return () => { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + for (const timer of settleTimers) window.clearTimeout(timer); + window.removeEventListener('scroll', handleScroll, true); + window.visualViewport?.removeEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate); + }; }); $: if (show && !selectionOnly) { @@ -474,16 +655,38 @@ deleteModelTarget = null; }; - const ITEM_HEIGHT = 42; + const ITEM_HEIGHT = 32; const OVERSCAN = 10; let listScrollTop = 0; let listContainer; + let listViewportHeight = 288; + + const trackListViewport = (node: HTMLElement) => { + const updateHeight = () => { + listViewportHeight = node.clientHeight || 288; + }; + + updateHeight(); + + if (!('ResizeObserver' in window)) { + return { destroy() {} }; + } + + const observer = new ResizeObserver(updateHeight); + observer.observe(node); + + return { + destroy() { + observer.disconnect(); + } + }; + }; $: visibleStart = Math.max(0, Math.floor(listScrollTop / ITEM_HEIGHT) - OVERSCAN); $: visibleEnd = Math.min( filteredItems.length, - Math.ceil((listScrollTop + 256) / ITEM_HEIGHT) + OVERSCAN + Math.ceil((listScrollTop + listViewportHeight) / ITEM_HEIGHT) + OVERSCAN ); @@ -501,7 +704,7 @@
@@ -511,16 +714,17 @@ ? '' : 'outline-hidden focus:outline-hidden'}" aria-label={selectedModel - ? $i18n.t('Selected model: {{modelName}}', { modelName: selectedModel.label }) + ? $i18n.t('Selected model: {{modelName}}', { modelName: triggerLabel }) : placeholder} aria-haspopup="listbox" aria-expanded={show} id="model-selector-{id}-button" type="button" + {disabled} on:click={toggleOpen} >
- {#if selectedModel} - {selectedModel.label} - {:else} - {placeholder} - {/if} - + {triggerLabel} +
@@ -546,32 +746,30 @@
{#if searchEnabled} -
- +
+ { if (e.code === 'Enter' && filteredItems.length > 0) { - value = filteredItems[selectedModelIdx].value; - show = false; + selectItem(filteredItems[selectedModelIdx], selectedModelIdx); return; // dont need to scroll on selection } else if (e.code === 'ArrowDown') { e.stopPropagation(); @@ -592,132 +790,72 @@ }); }} /> + + {#if modelFilterItems.length > 0 || (multipleEnabled && items.length > 0)} +
+ {#if multipleEnabled && items.length > 0} + + + + {/if} + + {#if modelFilterItems.length > 0} + + {/if} +
+ {/if}
{/if} -
- {#if tags && items.filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false)).length > 0} -
{ - if (e.deltaY !== 0) { - e.preventDefault(); - e.currentTarget.scrollLeft += e.deltaY; - } - }} - > -
- {#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0} - - {/if} - - {#if items.find((item) => item.model?.connection_type === 'local')} - - {/if} - - {#if items.find((item) => item.model?.connection_type === 'external')} - - {/if} - - {#if items.find((item) => item.model?.direct)} - - {/if} - - {#each tags as tag} - - - - {/each} -
-
- {/if} -
- -
+
{#if filteredItems.length === 0} {#if items.length === 0 && $user?.role === 'admin'} -
-
+
+
{$i18n.t('No models available')}
- {:else}
-
+
{$i18n.t('No results found')}
@@ -725,10 +863,12 @@ {:else}
{ listScrollTop = listContainer.scrollTop; }} @@ -740,16 +880,15 @@ {selectedModelIdx} {item} {index} - {value} + value={primaryValue} {pinModelHandler} {unloadModelHandler} {deleteModelHandler} {selectionOnly} + {compareEnabled} + {selectedValues} onClick={() => { - value = item.value; - selectedModelIdx = index; - - show = false; + selectItem(item, index); }} /> {/each} @@ -765,7 +904,7 @@ placement="top-start" > +
+ {:else} +
+ {/if}