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 @@