refac
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 leading-none text-gray-600 dark:text-gray-400">
|
||||
<div>{$i18n.t('External Knowledge Sources')}</div>
|
||||
<span
|
||||
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{$i18n.t('Experimental')}
|
||||
</span>
|
||||
<ExperimentalBadge />
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Add Connection')}>
|
||||
|
||||
@@ -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 @@
|
||||
</AdminSettingSection>
|
||||
|
||||
<AdminSettingSection title={$i18n.t('Chat')}>
|
||||
<AdminSettingRow
|
||||
label={$i18n.t('Tool Permissions')}
|
||||
description={$i18n.t('Show Full access and Ask for approval in the chat input menu.')}
|
||||
let:labelId
|
||||
>
|
||||
<div slot="label" class="flex items-center gap-2">
|
||||
<span>{$i18n.t('Tool Permissions')}</span>
|
||||
<ExperimentalBadge />
|
||||
</div>
|
||||
<Switch bind:state={chatConfig.ENABLE_TOOL_PERMISSIONS} ariaLabelledbyId={labelId} />
|
||||
</AdminSettingRow>
|
||||
|
||||
<AdminSettingRow
|
||||
label={$i18n.t('Context Compaction')}
|
||||
description={$i18n.t(
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.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';
|
||||
|
||||
let modelUploadInputElement: HTMLInputElement;
|
||||
let showModelDeleteConfirm = false;
|
||||
@@ -938,7 +939,7 @@
|
||||
|
||||
<div class="pt-1">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<div class=" text-sm font-normal">{$i18n.t('Experimental')}</div>
|
||||
<div class="text-sm font-normal"><ExperimentalBadge /></div>
|
||||
<button
|
||||
class=" text-xs font-normal text-gray-500"
|
||||
type="button"
|
||||
|
||||
@@ -414,6 +414,15 @@
|
||||
let loadedChatIdProp = '';
|
||||
let currentDraftKey = '';
|
||||
|
||||
$: toolApprovalMode = params?.tool_approval_mode === 'ask' ? 'ask' : 'full';
|
||||
|
||||
const handleToolApprovalModeChange = (mode) => {
|
||||
params = {
|
||||
...params,
|
||||
tool_approval_mode: mode === 'ask' ? 'ask' : 'full'
|
||||
};
|
||||
};
|
||||
|
||||
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
|
||||
const byKey: Record<string, any> = {};
|
||||
const conflicts: any[] = [];
|
||||
@@ -619,6 +628,9 @@
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
||||
if (input.toolApprovalMode) {
|
||||
handleToolApprovalModeChange(input.toolApprovalMode);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} else {
|
||||
@@ -1396,6 +1408,9 @@
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
||||
if (input.toolApprovalMode) {
|
||||
handleToolApprovalModeChange(input.toolApprovalMode);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -4168,7 +4183,8 @@
|
||||
compactHandler={handleManualCompact}
|
||||
statusHandler={handleStatusCommand}
|
||||
forkHandler={handleForkChat}
|
||||
toolServers={$toolServers}
|
||||
{toolApprovalMode}
|
||||
onToolApprovalModeChange={handleToolApprovalModeChange}
|
||||
{generating}
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
@@ -4271,7 +4287,8 @@
|
||||
compactHandler={handleManualCompact}
|
||||
statusHandler={handleStatusCommand}
|
||||
forkHandler={handleForkChat}
|
||||
toolServers={$toolServers}
|
||||
{toolApprovalMode}
|
||||
onToolApprovalModeChange={handleToolApprovalModeChange}
|
||||
{generating}
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
@@ -4328,8 +4345,9 @@
|
||||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
bind:dragged
|
||||
{toolApprovalMode}
|
||||
onToolApprovalModeChange={handleToolApprovalModeChange}
|
||||
{pendingOAuthTools}
|
||||
toolServers={$toolServers}
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
{onSelect}
|
||||
|
||||
@@ -170,6 +170,8 @@
|
||||
export let imageGenerationEnabled = false;
|
||||
export let webSearchEnabled = false;
|
||||
export let codeInterpreterEnabled = false;
|
||||
export let toolApprovalMode = 'full';
|
||||
export let onToolApprovalModeChange: Function = () => {};
|
||||
|
||||
export let pendingOAuthTools = [];
|
||||
|
||||
@@ -225,7 +227,8 @@
|
||||
selectedFilterIds,
|
||||
imageGenerationEnabled,
|
||||
webSearchEnabled,
|
||||
codeInterpreterEnabled
|
||||
codeInterpreterEnabled,
|
||||
toolApprovalMode
|
||||
});
|
||||
|
||||
const inputVariableHandler = async (text: string): Promise<string> => {
|
||||
@@ -2045,6 +2048,8 @@
|
||||
bind:files
|
||||
selectedModels={selectedModelIds}
|
||||
{fileUploadCapableModels}
|
||||
{toolApprovalMode}
|
||||
{onToolApprovalModeChange}
|
||||
{screenCaptureHandler}
|
||||
{inputFilesHandler}
|
||||
uploadFilesHandler={() => {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import Clip from '$lib/components/icons/Clip.svelte';
|
||||
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
|
||||
import Refresh from '$lib/components/icons/Refresh.svelte';
|
||||
import Agile from '$lib/components/icons/Agile.svelte';
|
||||
import ClockRotateRight from '$lib/components/icons/ClockRotateRight.svelte';
|
||||
import Database from '$lib/components/icons/Database.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
@@ -45,11 +44,25 @@
|
||||
|
||||
export let onUpload: Function;
|
||||
export let onClose: Function;
|
||||
export let toolApprovalMode = 'full';
|
||||
export let onToolApprovalModeChange: Function = () => {};
|
||||
|
||||
let show = false;
|
||||
let tab = '';
|
||||
|
||||
let showAttachWebpageModal = false;
|
||||
const toolApprovalModes = [
|
||||
{
|
||||
value: 'full',
|
||||
label: 'Full access',
|
||||
description: 'Run tools without asking for approval.'
|
||||
},
|
||||
{
|
||||
value: 'ask',
|
||||
label: 'Ask for approval',
|
||||
description: 'Stop before each tool call until you allow or deny it.'
|
||||
}
|
||||
];
|
||||
|
||||
let fileUploadEnabled = true;
|
||||
$: fileUploadEnabled =
|
||||
@@ -58,6 +71,7 @@
|
||||
|
||||
let webUploadEnabled = true;
|
||||
$: webUploadEnabled = $user?.role === 'admin' || ($user?.permissions?.chat?.web_upload ?? true);
|
||||
$: toolPermissionsEnabled = $config?.features?.enable_tool_permissions ?? false;
|
||||
|
||||
$: if (!fileUploadEnabled && files.length > 0) {
|
||||
files = [];
|
||||
@@ -129,6 +143,47 @@
|
||||
class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
|
||||
in:fly={{ x: -20, duration: 150 }}
|
||||
>
|
||||
{#if toolPermissionsEnabled}
|
||||
<button
|
||||
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
|
||||
on:click={() => {
|
||||
tab = 'tool_permissions';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="size-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" />
|
||||
</svg>
|
||||
|
||||
<div class="flex items-center w-full justify-between min-w-0">
|
||||
<div class="line-clamp-1">
|
||||
{$i18n.t('Tool Permissions')}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
{$i18n.t(
|
||||
toolApprovalModes.find((mode) => mode.value === toolApprovalMode)?.label ??
|
||||
'Full access'
|
||||
)}
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="h-px mx-1 my-1 bg-gray-100 dark:bg-gray-800"></div>
|
||||
{/if}
|
||||
|
||||
<Tooltip
|
||||
content={fileUploadCapableModels.length !== selectedModels.length
|
||||
? $i18n.t('Model(s) do not support file upload')
|
||||
@@ -487,6 +542,55 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'tool_permissions'}
|
||||
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
|
||||
<button
|
||||
class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
tab = '';
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
|
||||
<div class="flex items-center w-full justify-between">
|
||||
<div>
|
||||
{$i18n.t('Tool Permissions')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-1 space-y-1">
|
||||
{#each toolApprovalModes as mode}
|
||||
<Tooltip content={$i18n.t(mode.description)} className="w-full">
|
||||
<button
|
||||
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
|
||||
on:click={() => {
|
||||
toolApprovalMode = mode.value;
|
||||
onToolApprovalModeChange(mode.value);
|
||||
tab = '';
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center w-full justify-between min-w-0">
|
||||
<div class="line-clamp-1">{$i18n.t(mode.label)}</div>
|
||||
{#if toolApprovalMode === mode.value}
|
||||
<svg
|
||||
class="size-3 shrink-0 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tab === 'knowledge'}
|
||||
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import StructuredOutputRenderer from './StructuredOutputRenderer.svelte';
|
||||
import {
|
||||
artifactCode,
|
||||
chatId,
|
||||
chatId as currentChatId,
|
||||
mobile,
|
||||
settings,
|
||||
showArtifacts,
|
||||
@@ -68,6 +68,7 @@
|
||||
};
|
||||
|
||||
export let id;
|
||||
export let chatId = '';
|
||||
export let content;
|
||||
/** @type {import('./structuredOutput').OutputItem[]} */
|
||||
export let output = [];
|
||||
@@ -153,7 +154,7 @@
|
||||
hasClosingCodeFence(raw) &&
|
||||
!autoOpenedArtifactIds.has(artifactId) &&
|
||||
!$mobile &&
|
||||
$chatId
|
||||
$currentChatId
|
||||
) {
|
||||
autoOpenedArtifactIds.add(artifactId);
|
||||
await tick();
|
||||
@@ -283,6 +284,8 @@
|
||||
{#if output?.length}
|
||||
<StructuredOutputRenderer
|
||||
{id}
|
||||
{chatId}
|
||||
{messageId}
|
||||
{output}
|
||||
{model}
|
||||
{save}
|
||||
@@ -304,6 +307,8 @@
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
{id}
|
||||
{chatId}
|
||||
{messageId}
|
||||
content={formatMessageContent(content)}
|
||||
{model}
|
||||
{save}
|
||||
@@ -326,7 +331,16 @@
|
||||
{#if extracted.detailsContent}
|
||||
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
|
||||
<div class="markdown-prose">
|
||||
<Markdown {id} content={extracted.detailsContent} {preview} {compactPreview} {done} />
|
||||
<Markdown
|
||||
{id}
|
||||
{chatId}
|
||||
{messageId}
|
||||
content={extracted.detailsContent}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{done}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if extracted.plainContent}
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
|
||||
|
||||
export let id = '';
|
||||
export let chatId = '';
|
||||
export let messageId = '';
|
||||
export let content;
|
||||
export let done = true;
|
||||
export let model = null;
|
||||
@@ -101,6 +103,8 @@
|
||||
<MarkdownTokens
|
||||
{tokens}
|
||||
{id}
|
||||
{chatId}
|
||||
{messageId}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
@@ -23,6 +23,8 @@
|
||||
type?: string;
|
||||
name?: string;
|
||||
done?: string;
|
||||
id?: string;
|
||||
status?: string;
|
||||
duration?: string;
|
||||
embeds?: string;
|
||||
arguments?: string;
|
||||
@@ -32,6 +34,9 @@
|
||||
export let messageDone = true;
|
||||
export let allowEmbeds = true;
|
||||
export let compactPreview = false;
|
||||
export let resolvable = false;
|
||||
export let resolvingCallId = '';
|
||||
export let onResolve: (callId: string, approved: boolean) => void = () => {};
|
||||
|
||||
let open = $settings?.expandDetails ?? false;
|
||||
|
||||
@@ -45,9 +50,13 @@
|
||||
|
||||
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
|
||||
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
|
||||
$: hasPending =
|
||||
!messageDone &&
|
||||
tokens.some((t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true');
|
||||
$: pendingToolTokens = tokens.filter(
|
||||
(t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'pending'
|
||||
);
|
||||
$: hasPending = pendingToolTokens.length > 0;
|
||||
$: hasRejected = tokens.some(
|
||||
(t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'rejected'
|
||||
);
|
||||
|
||||
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
|
||||
|
||||
@@ -102,7 +111,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
const detail = parts.join(', ');
|
||||
return detail;
|
||||
})();
|
||||
@@ -110,54 +118,120 @@
|
||||
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
</script>
|
||||
|
||||
<div {id} class="w-full">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<button
|
||||
class="w-fit py-0.5 text-left {compactPreview
|
||||
? 'text-xs'
|
||||
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Status icon -->
|
||||
{#if hasPending}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if toolCallCount > 0}
|
||||
<div class="text-emerald-500 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-400 dark:text-gray-500">
|
||||
<Sparkles className="size-3.5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary text -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
|
||||
>{prefixText}</span
|
||||
>
|
||||
{#if summaryText}
|
||||
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
<div {id} class="w-full min-w-0">
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex-1 min-w-0 py-0.5 text-left {compactPreview
|
||||
? 'text-xs'
|
||||
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
open = !open;
|
||||
}}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<!-- Status icon -->
|
||||
{#if hasPending}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if hasRejected}
|
||||
<div class="text-red-400 dark:text-red-500">
|
||||
<XMark className="size-4" strokeWidth="2.5" />
|
||||
</div>
|
||||
{:else if toolCallCount > 0}
|
||||
<div class="text-emerald-500 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
<div class="text-gray-400 dark:text-gray-500">
|
||||
<Sparkles className="size-3.5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary text -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
|
||||
>{prefixText}</span
|
||||
>
|
||||
{#if summaryText}
|
||||
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resolvable && pendingToolTokens.length === 1}
|
||||
{@const pendingCallId = pendingToolTokens[0]?.attributes?.id ?? ''}
|
||||
<span class="flex gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={!pendingCallId || resolvingCallId === pendingCallId}
|
||||
on:click|stopPropagation={() => onResolve(pendingCallId, true)}
|
||||
>
|
||||
{$i18n.t('Allow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={!pendingCallId || resolvingCallId === pendingCallId}
|
||||
on:click|stopPropagation={() => onResolve(pendingCallId, false)}
|
||||
>
|
||||
{$i18n.t('Deny')}
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !open && resolvable && pendingToolTokens.length > 1}
|
||||
<div class="mt-1 space-y-0.5">
|
||||
{#each pendingToolTokens as token}
|
||||
{@const pendingCallId = token?.attributes?.id ?? ''}
|
||||
<div class="flex items-center gap-2 py-1 px-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 flex-1 min-w-0 line-clamp-1">
|
||||
{token?.attributes?.name ?? $i18n.t('tool')}
|
||||
</span>
|
||||
<span class="flex gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={!pendingCallId || resolvingCallId === pendingCallId}
|
||||
on:click={() => onResolve(pendingCallId, true)}
|
||||
>
|
||||
{$i18n.t('Allow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={!pendingCallId || resolvingCallId === pendingCallId}
|
||||
on:click={() => onResolve(pendingCallId, false)}
|
||||
>
|
||||
{$i18n.t('Deny')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
|
||||
@@ -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 @@
|
||||
<blockquote dir="auto">
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={token.tokens}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -315,9 +345,12 @@
|
||||
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-${itemIdx}`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -352,9 +385,12 @@
|
||||
<div>
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-${itemIdx}`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -366,9 +402,12 @@
|
||||
{:else}
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-${itemIdx}`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -388,6 +427,9 @@
|
||||
messageDone={done}
|
||||
{compactPreview}
|
||||
{allowEmbeds}
|
||||
resolvable={!!chatId && !!messageId && save}
|
||||
{resolvingCallId}
|
||||
onResolve={resolveToolCall}
|
||||
>
|
||||
<div slot="content">
|
||||
{#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 @@
|
||||
<div class="mb-1.5" slot="content">
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-${detailIdx}-d`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={marked.lexer(decode(detailToken.text))}
|
||||
attributes={detailToken?.attributes}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -452,6 +500,9 @@
|
||||
id={`${id}-${tokenIdx}-tc`}
|
||||
attributes={token.attributes}
|
||||
resultContent={getDetailTextContent(token)}
|
||||
resolvable={!!chatId && !!messageId && save}
|
||||
resolving={resolvingCallId === token.attributes?.id}
|
||||
onResolve={(approved) => resolveToolCall(token.attributes?.id ?? '', approved)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
@@ -469,9 +520,12 @@
|
||||
<div class=" mb-1.5" slot="content">
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-d`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
tokens={marked.lexer(decode(token.text))}
|
||||
attributes={token?.attributes}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
|
||||
@@ -815,6 +815,8 @@
|
||||
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
|
||||
<ContentRenderer
|
||||
id={`${chatId}-${message.id}`}
|
||||
{chatId}
|
||||
messageId={message.id}
|
||||
content={message.content}
|
||||
output={message.output}
|
||||
sources={message.sources}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
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 @@
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
content={formatMessageContent(displayItem.text)}
|
||||
{model}
|
||||
{save}
|
||||
@@ -71,6 +99,9 @@
|
||||
tokens={displayItem.tokens}
|
||||
messageDone={done}
|
||||
{compactPreview}
|
||||
resolvable={!!chatId && !!messageId && save}
|
||||
{resolvingCallId}
|
||||
onResolve={resolveToolCall}
|
||||
>
|
||||
<div slot="content">
|
||||
{#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 @@
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
@@ -127,6 +164,9 @@
|
||||
id={`${id}-${displayItem.id}-tool-call`}
|
||||
attributes={detailToken.attributes}
|
||||
resultContent={detailToken.text}
|
||||
resolvable={!!chatId && !!messageId && save}
|
||||
resolving={resolvingCallId === detailToken.attributes?.id}
|
||||
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
@@ -144,8 +184,11 @@
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-detail`}
|
||||
{chatId}
|
||||
{messageId}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{save}
|
||||
{preview}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
|
||||
@@ -38,6 +38,7 @@ export type OutputDetailToken = {
|
||||
files?: string;
|
||||
embeds?: string;
|
||||
output?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,8 +125,9 @@ function getToolResultText(item?: OutputItem): string {
|
||||
}
|
||||
|
||||
function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string, OutputItem>) {
|
||||
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<string,
|
||||
}
|
||||
|
||||
return {
|
||||
summary: isDone ? 'Tool Executed' : 'Executing...',
|
||||
summary: status === 'pending' ? 'Tool Approval Needed' : isDone ? 'Tool Executed' : 'Executing...',
|
||||
text: getToolResultText(resultItem),
|
||||
attributes: {
|
||||
type: 'tool_calls',
|
||||
id: callId,
|
||||
name,
|
||||
done: isDone ? 'true' : 'false',
|
||||
status,
|
||||
arguments: stringifyAttribute(item.arguments ?? ''),
|
||||
files: stringifyAttribute(resultItem?.files),
|
||||
embeds: stringifyAttribute(resultItem?.embeds)
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
export let imageGenerationEnabled = false;
|
||||
export let codeInterpreterEnabled = false;
|
||||
export let webSearchEnabled = false;
|
||||
export let toolApprovalMode = 'full';
|
||||
export let onToolApprovalModeChange: Function = () => {};
|
||||
|
||||
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?')}
|
||||
|
||||
@@ -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'))}
|
||||
>
|
||||
<Tooltip
|
||||
slot="label"
|
||||
content={$i18n.t(
|
||||
'This is an experimental feature, it may not function as expected and is subject to change at any time.'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{$i18n.t('Memory')}
|
||||
<span class="text-[0.625rem] uppercase text-gray-400 dark:text-gray-600"
|
||||
>{$i18n.t('Experimental')}</span
|
||||
>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div slot="label" class="flex items-center gap-2">
|
||||
{$i18n.t('Memory')}
|
||||
<ExperimentalBadge />
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
bind:state={enableMemory}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import Tooltip from './Tooltip.svelte';
|
||||
|
||||
type I18nStore = Writable<{
|
||||
t: (key: string, options?: Record<string, unknown>) => 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.'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Tooltip {content} className="inline-flex">
|
||||
<span class="text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600">
|
||||
{label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -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}
|
||||
<!-- Tool call display -->
|
||||
<div
|
||||
class="{buttonClassName} cursor-pointer"
|
||||
class="{buttonClassName} w-full min-w-0 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={toggleOpen}
|
||||
on:keydown={toggleOpenOnKeydown}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-full font-normal flex items-center gap-1.5 {isExecuting
|
||||
class="w-full min-w-0 max-w-full font-normal flex items-center gap-1.5 {isExecuting
|
||||
? 'shimmer'
|
||||
: ''}"
|
||||
>
|
||||
@@ -149,6 +156,10 @@
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if isRejected}
|
||||
<div class="text-red-400 dark:text-red-500">
|
||||
<XMark className="size-4" strokeWidth="2.5" />
|
||||
</div>
|
||||
{:else if isDone}
|
||||
<div class="text-emerald-500 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
@@ -160,27 +171,52 @@
|
||||
{/if}
|
||||
|
||||
<!-- Label -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<div class="flex-1 min-w-0 line-clamp-1">
|
||||
<!-- Short label (below md) -->
|
||||
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
|
||||
<!-- Full label (md and above) -->
|
||||
<span class="hidden @md:inline font-normal">
|
||||
{#if isDone}
|
||||
{$i18n.t('View Result from {{NAME}}', { NAME: attributes.name })}
|
||||
{:else if needsApproval}
|
||||
{$i18n.t('Allow {{NAME}}?', { NAME: attributes.name })}
|
||||
{:else if isRejected}
|
||||
{$i18n.t('Denied {{NAME}}', { NAME: attributes.name })}
|
||||
{:else}
|
||||
{$i18n.t('Executing {{NAME}}...', { NAME: attributes.name })}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center translate-y-[1px]">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if needsApproval}
|
||||
<span class="flex gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={resolving}
|
||||
on:click|stopPropagation={() => onResolve(true)}
|
||||
>
|
||||
{$i18n.t('Allow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
|
||||
disabled={resolving}
|
||||
on:click|stopPropagation={() => onResolve(false)}
|
||||
>
|
||||
{$i18n.t('Deny')}
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center translate-y-[1px]">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user