From 7d99b2716a0472b2100b3a71825d8eb3fcbbe877 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 19:59:11 -0600 Subject: [PATCH] refac --- backend/open_webui/config.py | 3 + backend/open_webui/main.py | 45 +++ backend/open_webui/models/chats.py | 4 + backend/open_webui/routers/chats.py | 4 +- backend/open_webui/utils/middleware.py | 311 +++++++++++++++++- backend/open_webui/utils/misc.py | 18 +- backend/open_webui/utils/payload.py | 2 + backend/open_webui/utils/tool_approval.py | 172 ++++++++++ src/lib/apis/chats/index.ts | 40 +++ .../admin/Settings/ExternalKnowledge.svelte | 7 +- .../admin/Settings/Interface.svelte | 19 +- .../Models/Manage/ManageOllama.svelte | 3 +- src/lib/components/chat/Chat.svelte | 24 +- src/lib/components/chat/MessageInput.svelte | 7 +- .../chat/MessageInput/InputMenu.svelte | 106 +++++- .../chat/Messages/ContentRenderer.svelte | 20 +- .../components/chat/Messages/Markdown.svelte | 4 + .../Markdown/ConsecutiveDetailsGroup.svelte | 172 +++++++--- .../Messages/Markdown/MarkdownTokens.svelte | 56 +++- .../chat/Messages/ResponseMessage.svelte | 2 + .../Messages/StructuredOutputRenderer.svelte | 45 ++- .../chat/Messages/structuredOutput.ts | 7 +- src/lib/components/chat/Placeholder.svelte | 6 +- .../chat/Settings/Personalization.svelte | 18 +- .../common/ExperimentalBadge.svelte | 22 ++ .../components/common/ToolCallDisplay.svelte | 62 +++- src/lib/stores/index.ts | 1 + 27 files changed, 1069 insertions(+), 111 deletions(-) create mode 100644 backend/open_webui/utils/tool_approval.py create mode 100644 src/lib/components/common/ExperimentalBadge.svelte diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 0643575c14..262e7c0818 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2187,6 +2187,8 @@ CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '') ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true' +ENABLE_TOOL_PERMISSIONS = os.getenv('ENABLE_TOOL_PERMISSIONS', 'False').lower() == 'true' + CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000')) _CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP') @@ -3119,6 +3121,7 @@ DEFAULT_CONFIG = { 'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP, 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, + 'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, 'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index fb558cfe63..4a5d04db15 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -238,6 +238,7 @@ from open_webui.utils.logger import start_logger from open_webui.utils.middleware import ( background_tasks_handler, build_chat_response_context, + drain_approved_tool_calls, process_chat_payload, process_chat_response, ) @@ -265,6 +266,11 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.redis import get_redis_client from open_webui.utils.security_headers import SecurityHeadersMiddleware from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper +from open_webui.utils.tool_approval import ( + ResolveToolCallForm, + build_tool_approval_resume_payload, + resolve_tool_call_output, +) from open_webui.utils.tools import set_terminal_servers, set_tool_servers if SAFE_MODE: @@ -1225,6 +1231,12 @@ async def chat_completion( or model_info_params.get('function_calling') or 'native' ), + 'tool_approval_mode': ( + form_data.get('params', {}).get('tool_approval_mode') + if await Config.get('chat.tool_permissions.enable', False) + else 'full' + ) + or 'full', }, } @@ -1576,6 +1588,9 @@ async def chat_completion( try: form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) + if await drain_approved_tool_calls(request, form_data, user, model, metadata): + return {'status': True, 'chat_id': metadata.get('chat_id'), 'paused': True} + response = await chat_completion_handler(request, form_data, user) # When the upstream provider returns an error (e.g. HTTP 400 @@ -1817,6 +1832,34 @@ async def chat_completion( generate_chat_completions = chat_completion generate_chat_completion = chat_completion +@app.post('/api/v1/chats/{id}/messages/{message_id}/resolve') +async def resolve_chat_message_tool_call( + request: Request, + id: str, + message_id: str, + form_data: ResolveToolCallForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + resolution = await resolve_tool_call_output(id, message_id, form_data, user, db=db) + if form_data.action != 'approve' and resolution['paused']: + return { + 'status': True, + 'chat_id': id, + 'message_id': message_id, + 'paused': True, + } + + payload = await build_tool_approval_resume_payload(id, message_id, chat=resolution['chat']) + result = await chat_completion(request, payload, user) + return { + 'status': True, + 'chat_id': id, + 'message_id': message_id, + **(result if isinstance(result, dict) else {}), + } + + # Expose as app.state so internal callers (e.g. automations) can # use the full pipeline without importing from main.py (avoids circular deps). app.state.CHAT_COMPLETION_HANDLER = chat_completion @@ -2134,6 +2177,7 @@ async def get_app_config(request: Request): 'automations.enable', 'notes.enable', 'chat.context_compaction.enable', + 'chat.tool_permissions.enable', 'web.search.enable', 'web.search.confirmation.enable', 'web.search.confirmation.content', @@ -2211,6 +2255,7 @@ async def get_app_config(request: Request): 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), 'enable_context_compaction': config.get('chat.context_compaction.enable'), + 'enable_tool_permissions': config.get('chat.tool_permissions.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/models/chats.py b/backend/open_webui/models/chats.py index 1a82c9f523..8e39edf456 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1035,6 +1035,10 @@ class ChatTable: return history_messages async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None: + messages_map = await ChatMessages.get_messages_map_by_chat_id(id) + if messages_map and message_id in messages_map: + return messages_map[message_id] + chat = await self.get_chat_by_id(id) if chat is None: return None diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 636cdd44ca..f4764599b5 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,8 +1,6 @@ from __future__ import annotations -import asyncio import logging -from typing import Optional from uuid import uuid4 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status @@ -58,6 +56,7 @@ CHAT_CONFIG_KEYS = { 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap', 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', + 'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable', } @@ -140,6 +139,7 @@ class ChatConfigForm(BaseModel): CONTEXT_COMPACTION_TOKEN_CAP: int | None = None CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str + ENABLE_TOOL_PERMISSIONS: bool = False class CompactChatForm(BaseModel): diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a269843b73..50709b446b 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2982,6 +2982,261 @@ async def build_chat_response_context(request, form_data, user, model, metadata, } +async def execute_tool_call_for_output(request, form_data, user, metadata, event_caller, event_emitter, tool_call): + tools = metadata.get('tools', {}) + name = tool_call.get('function', {}).get('name', '') + tool_args = tool_call.get('function', {}).get('arguments', '{}') + params = {} + if tool_args and tool_args.strip(): + try: + params = JSONCodec.loads(tool_args) + except Exception: + try: + params = ast.literal_eval(tool_args) + except Exception as e: + log.debug(e) + return { + 'tool_call_id': tool_call.get('id', ''), + 'content': ( + 'Error: Tool call arguments could not be parsed. ' + 'The model generated malformed or incomplete JSON.' + ), + } + tool_call.setdefault('function', {})['arguments'] = JSONCodec.dumps(params) + + tool = tools.get(name) + if not tool: + return {'tool_call_id': tool_call.get('id', ''), 'content': f'Error: Tool "{name}" not found.'} + + 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: + if not event_caller: + result = 'Error: Browser session is not connected for this direct tool.' + else: + 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) + + result, files, embeds = await process_tool_result( + request, + name, + result, + tool_type, + direct_tool, + metadata, + user, + ) + + await terminal_event_handler(name, params, result, event_emitter) + + return { + 'tool_call_id': tool_call.get('id', ''), + 'content': str(result) if result else '', + **({'files': files} if files else {}), + **({'embeds': embeds} if embeds else {}), + } + + +def append_tool_result_output(output: list[dict], result: dict) -> None: + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + display_files.append(file_item) + + output.append( + { + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': result.get('tool_call_id', ''), + 'output': output_parts, + 'status': 'completed', + **({'files': display_files} if display_files else {}), + **({'embeds': result.get('embeds')} if result.get('embeds') else {}), + } + ) + + +async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool: + chat_id = metadata.get('chat_id') + message_id = metadata.get('message_id') or metadata.get('assistant_message_id') + if not is_saved_chat_id(chat_id) or not message_id: + return False + + message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + output = message.get('output') if message else None + if not isinstance(output, list): + return False + + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + approved_calls = [ + item + for item in output + if item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') == 'queued' + and item.get('approved') is True + and item.get('call_id') not in result_call_ids + ] + if not approved_calls: + return False + + event_emitter, event_caller = await get_event_emitter_and_caller(metadata) + changed = False + for item in approved_calls: + tool_call = { + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': item.get('arguments', '{}'), + }, + } + result = await execute_tool_call_for_output( + request, + form_data, + user, + metadata, + event_caller, + event_emitter, + tool_call, + ) + item['status'] = 'completed' + item['arguments'] = tool_call.get('function', {}).get('arguments', '{}') + append_tool_result_output(output, result) + changed = True + + if changed: + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + paused = any( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'pending', 'queued', 'requires_approval'} + and item.get('call_id') not in result_call_ids + for item in output + ) + if not paused: + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + } + ) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + {'done': False, 'output': output}, + touch=False, + ) + if event_emitter: + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'done': False, + 'output': output, + }, + } + ) + + db_messages = await load_messages_from_db(chat_id, metadata.get('user_message_id')) + if db_messages: + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if assistant_message: + db_messages.append( + { + k: v + for k, v in assistant_message.items() + if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage') + } + ) + form_data['messages'] = process_messages_with_output( + db_messages, + reasoning_format=get_reasoning_format(model), + ) + form_data['messages'] = sanitize_tool_pairs(form_data['messages']) + + return paused + + return False + + +async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[dict], form_data: dict, metadata: dict): + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + for item in output: + if item.get('type') == 'function_call' and not item.get('call_id') and item.get('id'): + item['call_id'] = item['id'] + + if ( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('call_id') not in result_call_ids + and item.get('status') != 'rejected' + ): + item['status'] = 'pending' + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + { + 'done': False, + 'output': output, + 'meta': { + **(metadata.get('tool_approval') or {}), + 'session_id': metadata.get('session_id'), + 'tool_ids': metadata.get('tool_ids') or [], + 'skill_ids': metadata.get('skill_ids') or [], + 'terminal_id': metadata.get('terminal_id'), + 'tool_servers': metadata.get('tool_servers'), + 'filter_ids': metadata.get('filter_ids') or [], + 'features': metadata.get('features') or {}, + 'variables': metadata.get('variables') or {}, + 'files': metadata.get('files') or [], + 'params': metadata.get('params') or {}, + }, + }, + touch=False, + ) + + def get_response_data(response): if isinstance(response, list) and len(response) == 1: # If the response is a single-item list, unwrap it #17213 @@ -4032,7 +4287,20 @@ async def streaming_chat_response_handler(response, ctx): # Initialize output: use existing from message if continuing, else create new existing_output = message.get('output') if message else None - if existing_output: + prior_output = [] + if existing_output and metadata.get('assistant_message_id'): + prior_output = list(existing_output) + if ( + prior_output + and prior_output[-1].get('type') == 'message' + and prior_output[-1].get('status') == 'in_progress' + ): + msg_parts = prior_output[-1].get('content', []) + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): + prior_output.pop() + output = [] + content_parts = [] + elif existing_output: output = existing_output else: # Only create an initial message item if there is content to initialize with @@ -4050,7 +4318,6 @@ async def streaming_chat_response_handler(response, ctx): output = [] usage = None - prior_output = [] last_response_id = None def full_output(): @@ -4900,6 +5167,29 @@ async def streaming_chat_response_handler(response, ctx): } ) + tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full') + if ( + tool_approval_mode == 'ask' + and is_saved_chat_id(metadata.get('chat_id')) + and metadata.get('message_id') + ): + await pause_for_tool_approval( + metadata['chat_id'], + metadata['message_id'], + output, + form_data, + metadata, + ) + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'output': full_output(), + }, + } + ) + return + await event_emitter( { 'type': 'chat:completion', @@ -5293,7 +5583,7 @@ async def streaming_chat_response_handler(response, ctx): { 'type': 'chat:completion', 'data': { - 'output': output, + 'output': full_output(), }, } ) @@ -5420,7 +5710,7 @@ async def streaming_chat_response_handler(response, ctx): { 'type': 'chat:completion', 'data': { - 'output': output, + 'output': full_output(), }, } ) @@ -5464,10 +5754,11 @@ async def streaming_chat_response_handler(response, ctx): if item.get('status') == 'in_progress': item['status'] = 'completed' + current_output = full_output() title = await Chats.get_chat_title_by_id(metadata['chat_id']) if save_to_chat else '' data = { 'done': True, - 'output': output, + 'output': current_output, 'title': title, **({'usage': usage} if usage else {}), } @@ -5480,7 +5771,7 @@ async def streaming_chat_response_handler(response, ctx): metadata['message_id'], { 'done': True, - 'output': output, + 'output': current_output, **({'usage': usage} if usage else {}), }, ) @@ -5497,7 +5788,7 @@ async def streaming_chat_response_handler(response, ctx): {'done': True}, ) - await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output) + await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), current_output) await event_emitter( { @@ -5507,8 +5798,8 @@ async def streaming_chat_response_handler(response, ctx): ) ctx['assistant_message'] = { - 'content': ''.join(content_parts) or get_output_text(output), - 'output': output, + 'content': ''.join(content_parts) or get_output_text(current_output), + 'output': current_output, **({'usage': usage} if usage else {}), } await outlet_filter_handler(ctx) @@ -5535,7 +5826,7 @@ async def streaming_chat_response_handler(response, ctx): metadata['message_id'], { 'done': True, - 'output': output, + 'output': full_output(), }, ) else: diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 24253059d2..ce79102005 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -293,9 +293,17 @@ def convert_output_to_messages( pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' pending_reasoning_details = [] pending_tool_image_urls = [] - function_call_ids = { - item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id') + completed_call_ids = { + item.get('call_id') + for item in output + if item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'completed', 'rejected'} } + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + function_call_ids = completed_call_ids & result_call_ids def flush_pending(): nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details @@ -355,6 +363,9 @@ def convert_output_to_messages( pending_content.append(text) elif item_type == 'function_call': + if item.get('call_id') not in function_call_ids: + continue + # Collect tool calls to batch into assistant message arguments = item.get('arguments', '{}') # Ensure arguments is always a JSON string @@ -372,6 +383,9 @@ def convert_output_to_messages( ) elif item_type == 'function_call_output': + if item.get('call_id') not in function_call_ids: + continue + # Flush any pending content/tool_calls before adding tool result flush_pending() diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index a0cd4788e7..7a197666de 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -95,6 +95,7 @@ def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None 'compact_token_threshold': int, 'system': str, 'note_id': str, + 'tool_approval_mode': str, } for key in list(params.keys()): @@ -149,6 +150,7 @@ def remove_open_webui_params(params: dict) -> dict: 'compact_token_threshold': int, 'system': str, 'note_id': str, + 'tool_approval_mode': str, } for key in list(params.keys()): diff --git a/backend/open_webui/utils/tool_approval.py b/backend/open_webui/utils/tool_approval.py new file mode 100644 index 0000000000..83bfd4dde9 --- /dev/null +++ b/backend/open_webui/utils/tool_approval.py @@ -0,0 +1,172 @@ +from typing import Any, Literal + +from fastapi import HTTPException, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.constants import ERROR_MESSAGES +from open_webui.models.chats import Chats +from open_webui.socket.main import get_event_emitter +from open_webui.utils.json_codec import JSONCodec + + +class ResolveToolCallForm(BaseModel): + call_id: str + action: Literal['approve', 'reject', 'answer'] + answers: Any | None = None + timed_out: bool = False + + +async def resolve_tool_call_output( + chat_id: str, + message_id: str, + form_data: ResolveToolCallForm, + user, + db: AsyncSession | None = None, +) -> dict: + chat = await Chats.get_chat_by_id(chat_id, db=db) + if not chat or (chat.user_id != user.id and user.role != 'admin'): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if not message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + output = message.get('output') or [] + if not isinstance(output, list): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Message has no resolvable output.') + + function_call = next( + ( + item + for item in output + if item.get('type') == 'function_call' + and (item.get('call_id') or item.get('id')) == form_data.call_id + ), + None, + ) + if not function_call: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Tool call not found.') + function_call.setdefault('call_id', form_data.call_id) + + if any( + item.get('type') == 'function_call_output' and item.get('call_id') == form_data.call_id for item in output + ) or function_call.get('status') not in {'pending', 'queued', 'requires_approval'}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call has already been resolved.') + + if form_data.action == 'approve': + function_call['status'] = 'queued' + function_call['approved'] = True + elif form_data.action == 'reject': + function_call['status'] = 'rejected' + output.append( + { + 'type': 'function_call_output', + 'id': f'fco_{form_data.call_id}', + 'call_id': form_data.call_id, + 'output': [{'type': 'input_text', 'text': 'Tool call was denied by the user.'}], + 'status': 'rejected', + } + ) + else: + function_call['status'] = 'completed' + answer_payload = {'answers': form_data.answers, 'timed_out': form_data.timed_out} + output.append( + { + 'type': 'function_call_output', + 'id': f'fco_{form_data.call_id}', + 'call_id': form_data.call_id, + 'output': [{'type': 'input_text', 'text': JSONCodec.dumps(answer_payload)}], + 'status': 'completed', + } + ) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + { + 'done': False, + 'output': output, + }, + touch=False, + ) + + event_emitter = await get_event_emitter( + { + 'user_id': chat.user_id, + 'chat_id': chat_id, + 'message_id': message_id, + }, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'output': output}}) + + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + paused = any( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'pending', 'queued', 'requires_approval'} + and item.get('call_id') not in result_call_ids + for item in output + ) + return {'chat': chat, 'message': message, 'output': output, 'paused': paused} + + +async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat=None) -> dict: + chat = chat or await Chats.get_chat_by_id(chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if not assistant_message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + user_message_id = assistant_message.get('parentId') + user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id) if user_message_id else None + if not user_message: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call parent message is missing.') + + chat_data = chat.chat or {} + message_meta = assistant_message.get('meta') if isinstance(assistant_message.get('meta'), dict) else {} + params = { + **(chat_data.get('params') or {}), + **(message_meta.get('params') if isinstance(message_meta.get('params'), dict) else {}), + } + if 'tool_approval_mode' not in params: + params['tool_approval_mode'] = 'ask' + + model_id = assistant_message.get('model') or next(iter(chat_data.get('models') or []), None) + if not model_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call message model is missing.') + + messages = [] + if params.get('system'): + messages.append({'role': 'system', 'content': params.get('system')}) + + return { + 'stream': params.get('stream_response', True), + 'model': model_id, + 'messages': messages, + 'params': params, + 'files': message_meta.get('files') or chat_data.get('files') or None, + 'filter_ids': message_meta.get('filter_ids') or None, + 'tool_ids': message_meta.get('tool_ids') or None, + 'skill_ids': message_meta.get('skill_ids') or None, + 'terminal_id': message_meta.get('terminal_id') or None, + 'tool_servers': message_meta.get('tool_servers') or None, + 'features': message_meta.get('features') or {}, + 'variables': message_meta.get('variables') or {}, + 'chat_variables': chat.variables, + 'session_id': message_meta.get('session_id'), + 'chat_id': chat_id, + 'id': message_id, + 'parent_id': user_message.get('parentId'), + 'user_message': user_message, + 'assistant_message_id': message_id, + } diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index a659fd9b08..5dca7283ba 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1369,6 +1369,46 @@ export const deleteChatMessageById = async (token: string, id: string, messageId return res; }; +export const resolveChatMessageToolCall = async ( + token: string, + id: string, + messageId: string, + callId: string, + action: 'approve' | 'reject' | 'answer', + options: { answers?: unknown; timed_out?: boolean } = {} +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}/resolve`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + call_id: callId, + action, + ...options + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorDetail(err); + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/components/admin/Settings/ExternalKnowledge.svelte b/src/lib/components/admin/Settings/ExternalKnowledge.svelte index 8ed6ae49c6..927b25e67a 100644 --- a/src/lib/components/admin/Settings/ExternalKnowledge.svelte +++ b/src/lib/components/admin/Settings/ExternalKnowledge.svelte @@ -9,6 +9,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte'; import DatabaseSettings from '$lib/components/icons/DatabaseSettings.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; @@ -797,11 +798,7 @@
{$i18n.t('External Knowledge Sources')}
- - {$i18n.t('Experimental')} - +
diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 5c54a52b6e..59f0fa1cf3 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -10,6 +10,7 @@ import Textarea from '$lib/components/common/Textarea.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; + import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte'; import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; import AdminSettingField from './AdminSettingField.svelte'; import AdminSettingRow from './AdminSettingRow.svelte'; @@ -48,7 +49,8 @@ CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000, CONTEXT_COMPACTION_TOKEN_CAP: 80000, CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40, - CONTEXT_COMPACTION_PROMPT_TEMPLATE: '' + CONTEXT_COMPACTION_PROMPT_TEMPLATE: '', + ENABLE_TOOL_PERMISSIONS: false }; let showTaskParameters = false; @@ -75,7 +77,8 @@ ...current, features: { ...current.features, - enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION + enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION, + enable_tool_permissions: chatConfig.ENABLE_TOOL_PERMISSIONS } } : current @@ -253,6 +256,18 @@ + +
+ {$i18n.t('Tool Permissions')} + +
+ +
+
-
{$i18n.t('Experimental')}
+
+ +
+ {/if} + + {:else if tab === 'tool_permissions'} +
+ + +
+ {#each toolApprovalModes as mode} + + + + {/each} +
+
{:else if tab === 'knowledge'}
+ + + {:else} + +
+ {#if open} + + {:else} + + {/if} +
{/if}
- +
+ + {#if !open && resolvable && pendingToolTokens.length > 1} +
+ {#each pendingToolTokens as token} + {@const pendingCallId = token?.attributes?.id ?? ''} +
+ + {token?.attributes?.name ?? $i18n.t('tool')} + + + + + +
+ {/each} +
+ {/if} {#if open}
diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index c113c572aa..94410eab80 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -8,9 +8,11 @@ import { marked, type Token } from 'marked'; import { copyToClipboard, unescapeHtml } from '$lib/utils'; + import { resolveChatMessageToolCall } from '$lib/apis/chats'; import { WEBUI_BASE_URL } from '$lib/constants'; import { settings } from '$lib/stores'; + import { toast } from 'svelte-sonner'; import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte'; import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte'; @@ -27,6 +29,8 @@ import ColonFenceBlock from './ColonFenceBlock.svelte'; export let id: string; + export let chatId = ''; + export let messageId = ''; export let tokens: Token[]; export let top = true; export let attributes = {}; @@ -98,7 +102,30 @@ .trim(); }; - $: detailButtonClassName = `w-fit py-0.5 ${ + let resolvingCallId = ''; + + const resolveToolCall = async (callId: string, approved: boolean) => { + if (!chatId || !messageId || !callId || resolvingCallId) { + return; + } + + resolvingCallId = callId; + try { + await resolveChatMessageToolCall( + localStorage.token, + chatId, + messageId, + callId, + approved ? 'approve' : 'reject' + ); + } catch (err) { + toast.error(String(err)); + } finally { + resolvingCallId = ''; + } + }; + + $: detailButtonClassName = `py-0.5 ${ compactPreview ? 'text-xs' : 'text-[0.9375rem]' } text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`; @@ -279,8 +306,11 @@
{#each token.items as detailToken, detailIdx} @@ -399,6 +441,9 @@ attributes={detailToken.attributes} resultContent={getDetailTextContent(detailToken)} grouped={true} + resolvable={!!chatId && !!messageId && save} + resolving={resolvingCallId === detailToken.attributes?.id} + onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)} open={$settings?.expandDetails ?? false} className="w-full" buttonClassName={detailButtonClassName} @@ -416,9 +461,12 @@
resolveToolCall(token.attributes?.id ?? '', approved)} open={$settings?.expandDetails ?? false} className="w-full space-y-2" buttonClassName={detailButtonClassName} @@ -469,9 +520,12 @@
import Collapsible from '$lib/components/common/Collapsible.svelte'; import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte'; + import { resolveChatMessageToolCall } from '$lib/apis/chats'; import { settings } from '$lib/stores'; + import { toast } from 'svelte-sonner'; import Markdown from './Markdown.svelte'; import ConsecutiveDetailsGroup from './Markdown/ConsecutiveDetailsGroup.svelte'; @@ -13,6 +15,8 @@ } from './structuredOutput'; export let id = ''; + export let chatId = ''; + export let messageId = ''; export let output: OutputItem[] = []; export let done = true; export let model = null; @@ -32,8 +36,30 @@ const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary; const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes; + let resolvingCallId = ''; - $: detailButtonClassName = `w-fit py-0.5 ${ + const resolveToolCall = async (callId: string, approved: boolean) => { + if (!chatId || !messageId || !callId || resolvingCallId) { + return; + } + + resolvingCallId = callId; + try { + await resolveChatMessageToolCall( + localStorage.token, + chatId, + messageId, + callId, + approved ? 'approve' : 'reject' + ); + } catch (err) { + toast.error(String(err)); + } finally { + resolvingCallId = ''; + } + }; + + $: detailButtonClassName = `py-0.5 ${ compactPreview ? 'text-xs' : 'text-[0.9375rem]' } text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`; @@ -46,6 +72,8 @@
{#each displayItem.tokens as detailToken, detailIndex} @@ -80,6 +111,9 @@ attributes={detailToken.attributes} resultContent={detailToken.text} grouped={true} + resolvable={!!chatId && !!messageId && save} + resolving={resolvingCallId === detailToken.attributes?.id} + onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)} open={$settings?.expandDetails ?? false} className="w-full" buttonClassName={detailButtonClassName} @@ -97,8 +131,11 @@
resolveToolCall(detailToken.attributes?.id ?? '', approved)} open={$settings?.expandDetails ?? false} className="w-full space-y-2" buttonClassName={detailButtonClassName} @@ -144,8 +184,11 @@
) { - const callId = item.call_id ?? ''; + const callId = item.call_id ?? item.id ?? ''; const resultItem = toolOutputByCallId[callId]; + const status = String(item.status ?? ''); const isDone = isDoneStatus(item.status) || !!resultItem; let name = item.name ?? ''; if (name === 'delegate_task') { @@ -143,13 +145,14 @@ function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record {}; export let onUpload: Function = (e) => {}; export let onUpdate: (data?: { file?: any }) => void = () => {}; @@ -65,7 +67,6 @@ export let onQueueEdit: (id: string) => void = () => {}; export let onQueueDelete: (id: string) => void = () => {}; - export let toolServers = []; export let dragged = false; @@ -238,7 +239,8 @@ bind:showCommands bind:dragged {pendingOAuthTools} - {toolServers} + {toolApprovalMode} + {onToolApprovalModeChange} {stopResponse} {createMessagePair} placeholder={$i18n.t('How can I help you today?')} diff --git a/src/lib/components/chat/Settings/Personalization.svelte b/src/lib/components/chat/Settings/Personalization.svelte index 454b55bee7..a2724797de 100644 --- a/src/lib/components/chat/Settings/Personalization.svelte +++ b/src/lib/components/chat/Settings/Personalization.svelte @@ -9,6 +9,7 @@ import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; + import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte'; import MemoryModal from './Personalization/MemoryModal.svelte'; import { deleteMemoriesByUserId, deleteMemoryById, getMemories } from '$lib/apis/memories'; import { toast } from 'svelte-sonner'; @@ -121,19 +122,10 @@ ) .replace($i18n.t('Manage'), $i18n.t('Add Memory'))} > - -
- {$i18n.t('Memory')} - {$i18n.t('Experimental')} -
-
+
+ {$i18n.t('Memory')} + +
+ import { getContext } from 'svelte'; + import type { Writable } from 'svelte/store'; + import Tooltip from './Tooltip.svelte'; + + type I18nStore = Writable<{ + t: (key: string, options?: Record) => string; + }>; + + const i18n: I18nStore = getContext('i18n'); + + $: label = $i18n.t('Experimental'); + $: content = $i18n.t( + 'This is an experimental feature, it may not function as expected and is subject to change at any time.' + ); + + + + + {label} + + diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte index c51c85081f..6aeb98d1e6 100644 --- a/src/lib/components/common/ToolCallDisplay.svelte +++ b/src/lib/components/common/ToolCallDisplay.svelte @@ -13,6 +13,7 @@ import Spinner from './Spinner.svelte'; import WrenchSolid from '../icons/WrenchSolid.svelte'; import CheckCircle from '../icons/CheckCircle.svelte'; + import XMark from '../icons/XMark.svelte'; import Image from './Image.svelte'; import FullHeightIframe from './FullHeightIframe.svelte'; import { settings } from '$lib/stores'; @@ -27,18 +28,22 @@ files?: string; embeds?: string; done?: string; + status?: string; } = {}; export let open = false; export let grouped = false; export let className = ''; + export let resolvable = false; + export let resolving = false; + export let onResolve: (approved: boolean) => void = () => {}; const RESULT_PREVIEW_LIMIT = 10000; let expandedResult = false; $: if (!open) expandedResult = false; export let buttonClassName = - 'w-fit py-1 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition'; + 'py-1 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition'; const componentId = id || uuidv4(); @@ -91,7 +96,9 @@ $: args = open || (Array.isArray(embeds) && embeds.length > 0) ? decode(attributes?.arguments ?? '') : ''; $: isDone = attributes?.done === 'true'; - $: isExecuting = attributes?.done && attributes?.done !== 'true'; + $: needsApproval = attributes?.status === 'pending' && resolvable; + $: isRejected = attributes?.status === 'rejected'; + $: isExecuting = !needsApproval && attributes?.done && attributes?.done !== 'true'; $: parsedArgs = parseArguments(args); $: parsedResult = parseJSONString(result); @@ -133,14 +140,14 @@ {:else}
@@ -149,6 +156,10 @@
+ {:else if isRejected} +
+ +
{:else if isDone}
@@ -160,27 +171,52 @@ {/if} -
+
{attributes.name}
- -
- {#if open} - - {:else} - - {/if} -
+ {#if needsApproval} + + + + + {:else} + +
+ {#if open} + + {:else} + + {/if} +
+ {/if}
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index abbd2c556d..85f46a31d6 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -327,6 +327,7 @@ type Config = { enable_admin_chat_access: boolean; enable_admin_analytics: boolean; enable_context_compaction?: boolean; + enable_tool_permissions?: boolean; enable_community_sharing: boolean; enable_memories: boolean; enable_plugins?: boolean;