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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user