mirror of
https://github.com/oobabooga/textgen.git
synced 2026-07-23 11:20:54 -05:00
Add tool call confirmation with inline approve/reject buttons
This commit is contained in:
32
css/main.css
32
css/main.css
@@ -1580,6 +1580,28 @@ audio {
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.tool-approval-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.tool-approval-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color-primary);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--button-secondary-background-fill);
|
||||
color: var(--button-secondary-text-color);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.tool-approval-btn:hover {
|
||||
background: var(--button-secondary-background-fill-hover);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1678,18 +1700,12 @@ strong {
|
||||
border: 1px solid var(--border-color-primary);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
background: var(--button-secondary-background-fill);
|
||||
color: var(--button-secondary-text-color);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .edit-control-button {
|
||||
border: 1px solid var(--border-color-dark);
|
||||
background-color: var(--light-gray);
|
||||
color: #efefef;
|
||||
}
|
||||
|
||||
/* --- Simple Version Navigation --- */
|
||||
.version-navigation {
|
||||
position: absolute;
|
||||
|
||||
@@ -22,6 +22,7 @@ import modules.shared as shared
|
||||
from modules import utils
|
||||
from modules.extensions import apply_extensions
|
||||
from modules.html_generator import (
|
||||
TOOL_APPROVAL_PENDING,
|
||||
chat_html_wrapper,
|
||||
convert_to_markdown,
|
||||
extract_thinking_block,
|
||||
@@ -46,6 +47,42 @@ from modules.web_search import add_web_search_attachments
|
||||
|
||||
_history_file_lock = threading.Lock()
|
||||
|
||||
_tool_approvals = {}
|
||||
_tool_approvals_lock = threading.Lock()
|
||||
|
||||
|
||||
def request_tool_approval(session_key, tool_name):
|
||||
"""Block until the user approves/rejects a tool call. Returns 'approve'|'always'|'reject'."""
|
||||
with _tool_approvals_lock:
|
||||
if session_key not in _tool_approvals:
|
||||
_tool_approvals[session_key] = {
|
||||
"event": threading.Event(),
|
||||
"result": None,
|
||||
"tool_name": None,
|
||||
"approved": set(),
|
||||
}
|
||||
session = _tool_approvals[session_key]
|
||||
session["event"].clear()
|
||||
session["result"] = None
|
||||
session["tool_name"] = tool_name
|
||||
while not session["event"].wait(timeout=0.5):
|
||||
if shared.stop_everything:
|
||||
session["tool_name"] = None
|
||||
return 'reject'
|
||||
session["tool_name"] = None
|
||||
return session["result"]
|
||||
|
||||
|
||||
def resolve_tool_approval(session_key, result):
|
||||
"""Called by button handlers to resolve a pending approval."""
|
||||
session = _tool_approvals.get(session_key)
|
||||
if not session:
|
||||
return
|
||||
if result == 'always' and session["tool_name"]:
|
||||
session["approved"].add(session["tool_name"])
|
||||
session["result"] = result
|
||||
session["event"].set()
|
||||
|
||||
|
||||
def strftime_now(format):
|
||||
return datetime.now().strftime(format)
|
||||
@@ -1470,19 +1507,43 @@ def generate_chat_reply_wrapper(text, state, regenerate=False, _continue=False):
|
||||
yield _render(), history
|
||||
|
||||
# Execute tools, store results, and replace placeholders with real results
|
||||
for i, tc in enumerate(parsed_calls):
|
||||
# Check for stop request before each tool execution
|
||||
if shared.stop_everything:
|
||||
for j in range(i, len(parsed_calls)):
|
||||
seq.append({'role': 'tool', 'content': 'Tool execution was cancelled by the user.', 'tool_call_id': parsed_calls[j]['id']})
|
||||
pending_placeholders[j] = f'<tool_call>{tc_headers[j]}\nCancelled\n</tool_call>'
|
||||
_session_key = state.get('unique_id', '')
|
||||
def _cancel_remaining(from_idx):
|
||||
for j in range(from_idx, len(parsed_calls)):
|
||||
seq.append({'role': 'tool', 'content': 'Tool execution was cancelled by the user.', 'tool_call_id': parsed_calls[j]['id']})
|
||||
pending_placeholders[j] = f'<tool_call>{tc_headers[j]}\nCancelled\n</tool_call>'
|
||||
|
||||
history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders)
|
||||
history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders)
|
||||
|
||||
for i, tc in enumerate(parsed_calls):
|
||||
if shared.stop_everything:
|
||||
_cancel_remaining(i)
|
||||
yield _render(), history
|
||||
break
|
||||
|
||||
fn_name = tc['function']['name']
|
||||
fn_args = tc['function'].get('arguments', {})
|
||||
|
||||
_approved = _tool_approvals[_session_key]["approved"] if _session_key in _tool_approvals else set()
|
||||
if state.get('confirm_tool_calls', False) and fn_name not in _approved:
|
||||
pending_placeholders[i] = f'<tool_call>{tc_headers[i]}\n{TOOL_APPROVAL_PENDING}\n</tool_call>'
|
||||
history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders)
|
||||
yield _render(), history
|
||||
|
||||
approval = request_tool_approval(_session_key, fn_name)
|
||||
|
||||
if approval == 'reject' and shared.stop_everything:
|
||||
_cancel_remaining(i)
|
||||
yield _render(), history
|
||||
break
|
||||
|
||||
if approval == 'reject':
|
||||
seq.append({'role': 'tool', 'content': 'Tool call was rejected by the user.', 'tool_call_id': tc['id']})
|
||||
pending_placeholders[i] = f'<tool_call>{tc_headers[i]}\nRejected\n</tool_call>'
|
||||
history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders)
|
||||
yield _render(), history
|
||||
continue
|
||||
|
||||
result = execute_tool(fn_name, fn_args, tool_executors)
|
||||
|
||||
seq.append({'role': 'tool', 'content': result, 'tool_call_id': tc['id']})
|
||||
|
||||
@@ -122,6 +122,9 @@ def extract_thinking_block(string):
|
||||
|
||||
|
||||
|
||||
TOOL_APPROVAL_PENDING = '\x00approval_pending'
|
||||
|
||||
|
||||
def build_tool_call_block(header, body, message_id, index):
|
||||
"""Build HTML for a tool call accordion block."""
|
||||
block_id = f"tool-call-{message_id}-{index}"
|
||||
@@ -137,6 +140,21 @@ def build_tool_call_block(header, body, message_id, index):
|
||||
</details>
|
||||
'''
|
||||
|
||||
if body == TOOL_APPROVAL_PENDING:
|
||||
return f'''
|
||||
<details class="thinking-block" open data-block-id="{block_id}">
|
||||
<summary class="thinking-header">
|
||||
{tool_svg_small}
|
||||
<span class="thinking-title">{html.escape(header)}</span>
|
||||
</summary>
|
||||
<div class="thinking-content tool-approval-buttons">
|
||||
<button class="tool-approval-btn" onclick="document.getElementById('tool-approve-btn').click()">Approve</button>
|
||||
<button class="tool-approval-btn" onclick="document.getElementById('tool-always-approve-btn').click()">Always approve</button>
|
||||
<button class="tool-approval-btn" onclick="document.getElementById('tool-reject-btn').click()">Reject</button>
|
||||
</div>
|
||||
</details>
|
||||
'''
|
||||
|
||||
# Build a plain <pre> directly to avoid highlight.js auto-detection
|
||||
escaped_body = html.escape(body)
|
||||
return f'''
|
||||
|
||||
@@ -260,6 +260,7 @@ settings = {
|
||||
'web_search_pages': 3,
|
||||
'selected_tools': [],
|
||||
'mcp_servers': '',
|
||||
'confirm_tool_calls': False,
|
||||
'prompt-notebook': '',
|
||||
'preset': 'Top-P' if (user_data_dir / 'presets/Top-P.yaml').exists() else None,
|
||||
'max_new_tokens': 512,
|
||||
|
||||
@@ -220,10 +220,13 @@ def load_mcp_tools(servers_str):
|
||||
|
||||
uncached = [s for s in servers if _mcp_server_id(s) not in _mcp_server_cache]
|
||||
if uncached:
|
||||
results = asyncio.run(asyncio.gather(
|
||||
*(_connect_mcp_server(s) for s in uncached),
|
||||
return_exceptions=True
|
||||
))
|
||||
async def _discover_uncached():
|
||||
return await asyncio.gather(
|
||||
*(_connect_mcp_server(s) for s in uncached),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
results = asyncio.run(_discover_uncached())
|
||||
for server, result in zip(uncached, results):
|
||||
sid = _mcp_server_id(server)
|
||||
if isinstance(result, Exception):
|
||||
|
||||
@@ -210,6 +210,7 @@ def list_interface_input_elements():
|
||||
'start_with',
|
||||
'selected_tools',
|
||||
'mcp_servers',
|
||||
'confirm_tool_calls',
|
||||
'mode',
|
||||
'chat_style',
|
||||
'chat-instruct_command',
|
||||
@@ -436,6 +437,7 @@ def setup_auto_save():
|
||||
'chat_template_str',
|
||||
'selected_tools',
|
||||
'mcp_servers',
|
||||
'confirm_tool_calls',
|
||||
|
||||
# Parameters tab (ui_parameters.py) - Generation parameters
|
||||
'preset_menu',
|
||||
|
||||
@@ -63,6 +63,11 @@ def create_ui():
|
||||
shared.gradio['Stop'] = gr.Button('Stop', elem_id='stop', visible=False)
|
||||
shared.gradio['Generate'] = gr.Button('Send', elem_id='Generate', variant='primary')
|
||||
|
||||
# Hidden buttons for tool approval (triggered via JS from inline HTML buttons)
|
||||
shared.gradio['tool_approve'] = gr.Button(visible=False, elem_id='tool-approve-btn')
|
||||
shared.gradio['tool_always_approve'] = gr.Button(visible=False, elem_id='tool-always-approve-btn')
|
||||
shared.gradio['tool_reject'] = gr.Button(visible=False, elem_id='tool-reject-btn')
|
||||
|
||||
# Hover menu buttons
|
||||
with gr.Column(elem_id='chat-buttons'):
|
||||
shared.gradio['Regenerate'] = gr.Button('Regenerate (Ctrl + Enter)', elem_id='Regenerate')
|
||||
@@ -110,6 +115,8 @@ def create_ui():
|
||||
with gr.Accordion('MCP servers', open=False):
|
||||
shared.gradio['mcp_servers'] = gr.Textbox(value=shared.settings.get('mcp_servers', ''), lines=3, max_lines=3, label='', info='One URL per line for HTTP servers. For headers: url,Header: value. For stdio servers, use user_data/mcp.json.', elem_classes=['add_scrollbar'])
|
||||
|
||||
shared.gradio['confirm_tool_calls'] = gr.Checkbox(value=shared.settings.get('confirm_tool_calls', False), label='Confirm tool calls', info='Ask for approval before executing each tool call.')
|
||||
|
||||
gr.HTML("<div class='sidebar-vertical-separator'></div>")
|
||||
|
||||
with gr.Row():
|
||||
@@ -287,6 +294,13 @@ def create_event_handlers():
|
||||
stop_everything_event, None, None, queue=False).then(
|
||||
chat.redraw_html, gradio(reload_arr), gradio('display'), show_progress=False)
|
||||
|
||||
shared.gradio['tool_approve'].click(
|
||||
lambda uid: chat.resolve_tool_approval(uid or '', 'approve'), gradio('unique_id'), None, queue=False)
|
||||
shared.gradio['tool_always_approve'].click(
|
||||
lambda uid: chat.resolve_tool_approval(uid or '', 'always'), gradio('unique_id'), None, queue=False)
|
||||
shared.gradio['tool_reject'].click(
|
||||
lambda uid: chat.resolve_tool_approval(uid or '', 'reject'), gradio('unique_id'), None, queue=False)
|
||||
|
||||
if not shared.args.multi_user:
|
||||
shared.gradio['unique_id'].select(
|
||||
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
|
||||
|
||||
Reference in New Issue
Block a user