fix: structured approvals + ToolCallGroup reactivity
- Replace text-based regex parsing in InlineApproval with structured pendingApprovals extracted from request_execution tool results. The tool result text is deterministic (not LLM-generated), making UUID extraction reliable regardless of how the LLM rephrases the response. - Fix ToolCallGroup reactivity: wasActive = active captured initial value. Now uses (active) so re-runs on prop changes. - Extract approvals in both live streaming (done event) and history loading for consistent behavior on resumed sessions.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { PendingApproval } from '$lib/stores/chat'
|
||||||
import { decideApproval } from '$lib/api'
|
import { decideApproval } from '$lib/api'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
@@ -6,62 +7,57 @@
|
|||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
|
||||||
let { text }: { text: string } = $props()
|
let { approvals }: { approvals: PendingApproval[] } = $props()
|
||||||
|
|
||||||
const RE = /\bexec[uecution]*\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i
|
|
||||||
const match = $derived(text.match(RE))
|
|
||||||
|
|
||||||
let pending = $state(false)
|
let pending = $state(false)
|
||||||
let done = $state<'approved' | 'denied' | null>(null)
|
let done = $state<string | null>(null)
|
||||||
|
let doneId = $state('')
|
||||||
|
|
||||||
async function approve() {
|
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
|
||||||
if (!match) return
|
|
||||||
pending = true
|
pending = true
|
||||||
const result = await decideApproval(match[1], 'approve')
|
doneId = approval.executionId
|
||||||
|
const result = await decideApproval(approval.executionId, decision)
|
||||||
pending = false
|
pending = false
|
||||||
done = result ? 'approved' : 'denied'
|
done = result ? decision : 'failed'
|
||||||
}
|
|
||||||
|
|
||||||
async function deny() {
|
|
||||||
if (!match) return
|
|
||||||
pending = true
|
|
||||||
const result = await decideApproval(match[1], 'deny')
|
|
||||||
pending = false
|
|
||||||
done = result ? 'denied' : 'denied'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
done = null
|
done = null
|
||||||
|
doneId = ''
|
||||||
pending = false
|
pending = false
|
||||||
void text
|
void approvals
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if match && !done}
|
{#each approvals.filter(a => !done || a.executionId !== doneId) as approval (approval.executionId)}
|
||||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
{#if !done || approval.executionId !== doneId}
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||||
<span class="flex-1 text-xs text-muted-foreground">This action requires approval</span>
|
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||||
{#if pending}
|
<span class="flex-1 text-xs text-muted-foreground">
|
||||||
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
{approval.action} on {approval.target} requires approval
|
||||||
{:else}
|
</span>
|
||||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={approve}>
|
{#if pending && doneId === approval.executionId}
|
||||||
<CheckIcon class="size-3" />
|
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
||||||
<span class="ml-1">Approve</span>
|
{:else}
|
||||||
</Button>
|
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={deny}>
|
<CheckIcon class="size-3" />
|
||||||
<XIcon class="size-3" />
|
<span class="ml-1">Approve</span>
|
||||||
<span class="ml-1">Deny</span>
|
</Button>
|
||||||
</Button>
|
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
||||||
{/if}
|
<XIcon class="size-3" />
|
||||||
</div>
|
<span class="ml-1">Deny</span>
|
||||||
{:else if done}
|
</Button>
|
||||||
<div class="my-2 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs {done === 'approved' ? 'border-success/40 bg-success/5 text-success' : 'border-destructive/40 bg-destructive/5 text-destructive'}">
|
{/if}
|
||||||
{#if done === 'approved'}
|
</div>
|
||||||
|
{:else if done === 'approved'}
|
||||||
|
<div class="my-2 flex items-center gap-2 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
|
||||||
<CheckIcon class="size-4" />
|
<CheckIcon class="size-4" />
|
||||||
<span>Approved. The action is running.</span>
|
<span>Approved. The action is running.</span>
|
||||||
{:else}
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||||
<XIcon class="size-4" />
|
<XIcon class="size-4" />
|
||||||
<span>Denied.</span>
|
<span>Denied.</span>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||||
|
|
||||||
let open = $state(false)
|
let open = $state(false)
|
||||||
let wasActive = active
|
let wasActive = $state(active)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (active && !wasActive) {
|
if (active && !wasActive) {
|
||||||
|
|||||||
@@ -2,11 +2,38 @@ import { writable, get } from 'svelte/store'
|
|||||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
executionId: string
|
||||||
|
action: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
text: string
|
text: string
|
||||||
tools: ToolCallResult[]
|
tools: ToolCallResult[]
|
||||||
|
pendingApprovals: PendingApproval[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
|
||||||
|
|
||||||
|
function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
|
||||||
|
const out: PendingApproval[] = []
|
||||||
|
for (const t of tools) {
|
||||||
|
if (t.name !== 'request_execution' || t.type !== 'tool_result') continue
|
||||||
|
const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '')
|
||||||
|
if (!text.includes('requires approval')) continue
|
||||||
|
const m = text.match(APPROVAL_RE)
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
executionId: m[1],
|
||||||
|
action: t.args?.action ?? 'unknown',
|
||||||
|
target: t.args?.target ?? 'unknown'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallResult {
|
export interface ToolCallResult {
|
||||||
@@ -57,12 +84,16 @@ export async function loadSessionMessages(sessionId: string) {
|
|||||||
currentSession.set(sessionId)
|
currentSession.set(sessionId)
|
||||||
const msgs = await fetchMessages(sessionId)
|
const msgs = await fetchMessages(sessionId)
|
||||||
sessionMessages.set(msgs)
|
sessionMessages.set(msgs)
|
||||||
const chatMsgs: ChatMessage[] = msgs.map((m) => ({
|
const chatMsgs: ChatMessage[] = msgs.map((m) => {
|
||||||
id: m.id,
|
const tools = mergeToolCalls(m.content?.tool_calls)
|
||||||
role: m.role as 'user' | 'assistant',
|
return {
|
||||||
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
id: m.id,
|
||||||
tools: mergeToolCalls(m.content?.tool_calls)
|
role: m.role as 'user' | 'assistant',
|
||||||
}))
|
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||||
|
tools,
|
||||||
|
pendingApprovals: extractApprovals(tools)
|
||||||
|
}
|
||||||
|
})
|
||||||
messages.set(chatMsgs)
|
messages.set(chatMsgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +105,8 @@ export function sendMessage(text: string) {
|
|||||||
id: mid(),
|
id: mid(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text,
|
text,
|
||||||
tools: []
|
tools: [],
|
||||||
|
pendingApprovals: []
|
||||||
}
|
}
|
||||||
messages.update((ms) => [...ms, userMsg])
|
messages.update((ms) => [...ms, userMsg])
|
||||||
|
|
||||||
@@ -82,7 +114,8 @@ export function sendMessage(text: string) {
|
|||||||
id: mid(),
|
id: mid(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: '',
|
text: '',
|
||||||
tools: []
|
tools: [],
|
||||||
|
pendingApprovals: []
|
||||||
}
|
}
|
||||||
messages.update((ms) => [...ms, assistantMsg])
|
messages.update((ms) => [...ms, assistantMsg])
|
||||||
|
|
||||||
@@ -147,6 +180,13 @@ export function sendMessage(text: string) {
|
|||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
|
messages.update((ms) => {
|
||||||
|
const last = ms[ms.length - 1]
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
last.pendingApprovals = extractApprovals(last.tools)
|
||||||
|
}
|
||||||
|
return [...ms]
|
||||||
|
})
|
||||||
currentSession.set(ev.data?.session_id ?? ev.session_id)
|
currentSession.set(ev.data?.session_id ?? ev.session_id)
|
||||||
} else if (ev.type === 'error') {
|
} else if (ev.type === 'error') {
|
||||||
error.set(ev.data)
|
error.set(ev.data)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex w-full flex-col gap-2">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
||||||
<InlineApproval text={msg.text} />
|
<InlineApproval approvals={msg.pendingApprovals} />
|
||||||
{#if msg.text}
|
{#if msg.text}
|
||||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
|
|||||||
Reference in New Issue
Block a user