Add tool confirmation UI with Accept/Decline for write operations

Before executing any write tool (create/update/delete), the backend now
pauses with an asyncio.Future and emits a tool_pending SSE event. The
frontend displays a ToolConfirmCard with Accept and Decline buttons.
Clicking Accept resolves the Future and proceeds; Decline records a
declined tool_call chip and falls through to regular streaming. Typing
single-word yes/no responses (e.g. "yes", "cancel") also works as
confirmation. 120s timeout auto-declines if no response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:43:47 -05:00
parent 1b63371bb3
commit 815eed2574
8 changed files with 304 additions and 43 deletions
+13 -2
View File
@@ -11,6 +11,7 @@ const appliedTags = ref<Set<string>>(new Set());
const applyingTag = ref<string | null>(null);
const label = computed(() => {
if (props.toolCall.status === "declined") return "Declined";
if (!props.toolCall.result.success) return "Error";
switch (props.toolCall.result.type) {
case "task":
@@ -180,9 +181,12 @@ async function applyTag(tag: string) {
</script>
<template>
<div class="tool-call-card" :class="{ error: toolCall.status === 'error' }">
<div class="tool-call-card" :class="{ error: toolCall.status === 'error', declined: toolCall.status === 'declined' }">
<span class="tool-label">{{ label }}</span>
<template v-if="toolCall.status === 'error'">
<template v-if="toolCall.status === 'declined'">
<span class="tool-declined-name">{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}</span>
</template>
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
<template v-else-if="searchResults">
@@ -292,6 +296,13 @@ async function applyTag(tag: string) {
.tool-call-card.error {
border-color: var(--color-danger, #e74c3c);
}
.tool-call-card.declined {
opacity: 0.55;
}
.tool-declined-name {
text-decoration: line-through;
color: var(--color-text-muted);
}
.tool-label {
font-weight: 600;
color: var(--color-text-muted);
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ToolPendingRecord } from "@/types/chat";
const props = defineProps<{
pendingTool: ToolPendingRecord;
}>();
const emit = defineEmits<{
(e: "accept"): void;
(e: "decline"): void;
}>();
const label = computed(() => props.pendingTool.label ?? props.pendingTool.function);
const detail = computed(() => {
const args = props.pendingTool.arguments;
const name =
(args.title as string | undefined) ??
(args.summary as string | undefined) ??
(args.query as string | undefined) ??
"";
return name ? `"${name}"` : "";
});
</script>
<template>
<div class="tool-confirm-card">
<div class="tool-confirm-info">
<span class="tool-confirm-label">{{ label }}</span>
<span v-if="detail" class="tool-confirm-detail">{{ detail }}</span>
</div>
<div class="tool-confirm-actions">
<button class="btn-accept" @click="emit('accept')">Accept</button>
<button class="btn-decline" @click="emit('decline')">Decline</button>
</div>
</div>
</template>
<style scoped>
.tool-confirm-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-warning, #f59e0b);
border-radius: 12px;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
margin-top: 0.4rem;
width: 100%;
box-sizing: border-box;
}
.tool-confirm-info {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
flex: 1;
}
.tool-confirm-label {
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 0.7rem;
white-space: nowrap;
}
.tool-confirm-detail {
color: var(--color-text);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-confirm-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-accept,
.btn-decline {
padding: 0.25rem 0.65rem;
border: none;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accept {
background: var(--color-success, #2ecc71);
color: #fff;
}
.btn-decline {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.btn-accept:hover {
opacity: 0.85;
}
.btn-decline:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
+40
View File
@@ -17,6 +17,7 @@ import type {
OllamaStatus,
SendMessageResponse,
ToolCallRecord,
ToolPendingRecord,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
@@ -28,6 +29,7 @@ export const useChatStore = defineStore("chat", () => {
const streamingContent = ref("");
const streamingToolCalls = ref<ToolCallRecord[]>([]);
const streamingStatus = ref("");
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
const lastContextMeta = ref<ContextMeta | null>(null);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
@@ -124,6 +126,22 @@ export const useChatStore = defineStore("chat", () => {
const convId = currentConversation.value.id;
lastContextMeta.value = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
// responses rather than sending them as a new message.
if (streamingPendingTool.value) {
const lower = content.trim().toLowerCase();
const YES = new Set(["yes", "y", "ok", "sure", "proceed", "accept", "confirm", "do it", "go ahead", "yeah", "yep"]);
const NO = new Set(["no", "n", "cancel", "decline", "stop", "nope", "abort", "don't"]);
if (YES.has(lower)) {
await confirmTool(true);
return;
}
if (NO.has(lower)) {
await confirmTool(false);
return;
}
}
// Add optimistic user message
const userMsg: Message = {
id: -Date.now(), // Temporary ID
@@ -188,7 +206,12 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value += event.data.chunk as string;
streamingStatus.value = "";
break;
case "tool_pending":
streamingPendingTool.value = event.data.tool_pending as ToolPendingRecord;
break;
case "tool_call":
// Receiving a tool_call clears the pending confirmation card
streamingPendingTool.value = null;
streamingToolCalls.value = [
...streamingToolCalls.value,
event.data.tool_call as ToolCallRecord,
@@ -215,6 +238,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
streaming.value = false;
// Update conversation in list
@@ -231,6 +255,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
useToastStore().show(
"Chat error: " + (event.data.error as string),
"error",
@@ -269,6 +294,19 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
// Optimistically clear so buttons disappear immediately
streamingPendingTool.value = null;
try {
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
} catch {
// Server will auto-decline on timeout — no action needed
}
}
async function cancelGeneration() {
@@ -353,6 +391,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent,
streamingToolCalls,
streamingStatus,
streamingPendingTool,
lastContextMeta,
ollamaStatus,
modelStatus,
@@ -364,6 +403,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation,
updateTitle,
sendMessage,
confirmTool,
cancelGeneration,
saveMessageAsNote,
summarizeAsNote,
+7 -1
View File
@@ -10,7 +10,13 @@ export interface ToolCallRecord {
function: string;
arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
status: "success" | "error";
status: "success" | "error" | "declined";
}
export interface ToolPendingRecord {
function: string;
arguments: Record<string, unknown>;
label?: string;
}
export interface Message {
+7
View File
@@ -7,6 +7,7 @@ import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
const route = useRoute();
@@ -402,6 +403,12 @@ onUnmounted(() => {
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
+22
View File
@@ -187,6 +187,28 @@ async def generation_stream_route(conv_id: int):
)
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
@login_required
async def confirm_generation_route(conv_id: int):
"""Resolve a pending tool confirmation (accept or decline)."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
if buf.confirmation_future is None or buf.confirmation_future.done():
return jsonify({"error": "No pending tool confirmation"}), 409
data = await request.get_json(force=True, silent=True) or {}
decision = bool(data.get("confirmed", False))
buf.confirmation_future.set_result(decision)
return jsonify({"status": "ok", "confirmed": decision})
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
@login_required
async def cancel_generation_route(conv_id: int):
@@ -37,6 +37,9 @@ class GenerationBuffer:
finished_at: float | None = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
_notify: asyncio.Event = field(default_factory=asyncio.Event)
# Tool confirmation — set when a write tool is awaiting user approval
confirmation_future: asyncio.Future | None = None
pending_tool: dict | None = None
def append_event(self, event_type: str, data: dict) -> SSEEvent:
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
+104 -40
View File
@@ -50,6 +50,13 @@ _TOOL_LABELS: dict[str, str] = {
"delete_todo": "Removing todo",
}
# Tools that write data and require explicit user confirmation before executing.
_WRITE_TOOLS: frozenset[str] = frozenset({
"create_task", "create_note", "update_note",
"create_event", "update_event", "delete_event",
"create_todo", "update_todo", "complete_todo", "delete_todo",
})
# Action phrases used in the acknowledgment prompt — "You are about to: {action}"
_TOOL_ACTIONS: dict[str, str] = {
"create_task": "create a task",
@@ -238,50 +245,107 @@ async def run_generation(
intent.confidence, intent.tool_name,
)
if intent.should_execute:
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(intent.tool_name, 'Working')}..."})
tool_name = intent.tool_name
confirmed = True # Non-write tools auto-confirm
# Run tool execution and acknowledgment generation in parallel.
# The acknowledgment uses the fast intent model (already in VRAM),
# so the user sees text within ~200-400ms instead of waiting for
# the full main-model TTFT (~22s).
t_tool = time.monotonic()
result, ack_text = await asyncio.gather(
execute_tool(user_id, intent.tool_name, intent.arguments),
_generate_acknowledgment(user_content, intent.tool_name, intent_model),
)
timing["tools"].append({"name": intent.tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success"))
if tool_name in _WRITE_TOOLS:
# Pause and ask the user to accept or decline before executing.
loop = asyncio.get_running_loop()
confirm_future: asyncio.Future = loop.create_future()
buf.confirmation_future = confirm_future
buf.pending_tool = {
"function": tool_name,
"arguments": intent.arguments,
"label": _TOOL_LABELS.get(tool_name, "Action"),
}
buf.append_event("status", {"status": "Waiting for confirmation..."})
buf.append_event("tool_pending", {"tool_pending": buf.pending_tool})
# Stream acknowledgment immediately — user sees text before main LLM starts
if ack_text:
buf.append_event("chunk", {"chunk": ack_text})
buf.content_so_far += ack_text
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
cancel_task = asyncio.create_task(buf.cancel_event.wait())
confirmed = False
try:
done_set, _ = await asyncio.wait(
{confirm_future, cancel_task},
timeout=120.0,
return_when=asyncio.FIRST_COMPLETED,
)
if cancel_task in done_set:
cancelled = True
elif confirm_future in done_set:
try:
confirmed = bool(confirm_future.result())
except Exception:
confirmed = False
# else: timeout → confirmed stays False
except Exception:
confirmed = False
finally:
cancel_task.cancel()
buf.confirmation_future = None
buf.pending_tool = None
tool_record = {
"function": intent.tool_name,
"arguments": intent.arguments,
"result": result,
"status": "success" if result.get("success") else "error",
}
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
if not confirmed:
if not cancelled:
# Record the declined action so the UI can show it
declined_record = {
"function": tool_name,
"arguments": intent.arguments,
"result": {"success": False, "error": "Declined"},
"status": "declined",
}
all_tool_calls.append(declined_record)
buf.append_event("tool_call", {"tool_call": declined_record})
# Fall through to streaming without tool context
# Include ack as the assistant's partial response so round 1
# continues coherently from where the acknowledgment left off
messages.append({
"role": "assistant",
"content": ack_text,
"tool_calls": [
{"function": {"name": intent.tool_name, "arguments": intent.arguments}}
],
})
messages.append({
"role": "tool",
"content": json.dumps(result),
})
continue # Next round: LLM streams response incorporating result
if confirmed:
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(tool_name, 'Working')}..."})
# Run tool execution and acknowledgment generation in parallel.
# The acknowledgment uses the fast intent model (already in VRAM),
# so the user sees text within ~200-400ms instead of waiting for
# the full main-model TTFT (~22s).
t_tool = time.monotonic()
result, ack_text = await asyncio.gather(
execute_tool(user_id, tool_name, intent.arguments),
_generate_acknowledgment(user_content, tool_name, intent_model),
)
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Intent-routed tool %s result: success=%s", tool_name, result.get("success"))
# Stream acknowledgment immediately — user sees text before main LLM starts
if ack_text:
buf.append_event("chunk", {"chunk": ack_text})
buf.content_so_far += ack_text
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
tool_record = {
"function": tool_name,
"arguments": intent.arguments,
"result": result,
"status": "success" if result.get("success") else "error",
}
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
# Include ack as the assistant's partial response so round 1
# continues coherently from where the acknowledgment left off
messages.append({
"role": "assistant",
"content": ack_text,
"tool_calls": [
{"function": {"name": tool_name, "arguments": intent.arguments}}
],
})
messages.append({
"role": "tool",
"content": json.dumps(result),
})
continue # Next round: LLM streams response incorporating result
# Bail out here if cancelled during confirmation wait
if cancelled:
break
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
t_stream = time.monotonic()