fix: dedup request_execution, persistent approval bar, JSON payload
- Add dedup in request_execution: check entities(type,name) uniqueness before creating duplicate executions. Returns 'already queued' message to the LLM, preventing tool-calling loops. - Fix createApproval JSON payload: use json.Marshal instead of fmt.Sprintf to escape params (could contain unescaped double quotes from JSON config). - Add ON CONFLICT DO NOTHING to entity/execution inserts for dedup race safety. - Persistent approval bar at top of Chat.svelte: aggregates pendingApprovals from all messages, fixed position (won't scroll away). Approve/deny/approve-all. - Update SOUL.md: agent must STOP after queuing a gated action. - Fix ToolCallGroup reactivity: wasActive = (active).
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import { messages, streaming, sendMessage, cancelStream, error, type PendingApproval } from '$lib/stores/chat'
|
||||
import { decideApproval } from '$lib/api'
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
@@ -15,6 +19,43 @@
|
||||
|
||||
let input = $state('')
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
let approving = $state<string | null>(null)
|
||||
let approvedIds = $state(new Set<string>())
|
||||
|
||||
const pendingApprovals = $derived.by(() => {
|
||||
const msgs = $messages
|
||||
const all: PendingApproval[] = []
|
||||
for (const m of msgs) {
|
||||
all.push(...m.pendingApprovals)
|
||||
}
|
||||
return all.filter(a => !approvedIds.has(a.executionId))
|
||||
})
|
||||
|
||||
async function approveAll() {
|
||||
for (const a of pendingApprovals) {
|
||||
approving = a.executionId
|
||||
await decideApproval(a.executionId, 'approve')
|
||||
approvedIds.add(a.executionId)
|
||||
approvedIds = approvedIds
|
||||
}
|
||||
approving = null
|
||||
}
|
||||
|
||||
async function approveOne(a: PendingApproval) {
|
||||
approving = a.executionId
|
||||
await decideApproval(a.executionId, 'approve')
|
||||
approvedIds.add(a.executionId)
|
||||
approvedIds = approvedIds
|
||||
approving = null
|
||||
}
|
||||
|
||||
async function denyOne(a: PendingApproval) {
|
||||
approving = a.executionId
|
||||
await decideApproval(a.executionId, 'deny')
|
||||
approvedIds.add(a.executionId)
|
||||
approvedIds = approvedIds
|
||||
approving = null
|
||||
}
|
||||
|
||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||
const RAIL_MIN = 260
|
||||
@@ -89,6 +130,38 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{#if pendingApprovals.length > 0}
|
||||
<div class="shrink-0 border-b border-warning/30 bg-warning/5 px-4 py-2">
|
||||
{#each pendingApprovals as a (a.executionId)}
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||
<span class="flex-1 text-xs font-medium">
|
||||
{a.action} on {a.target}
|
||||
</span>
|
||||
{#if approving === a.executionId}
|
||||
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => approveOne(a)}>
|
||||
<CheckIcon class="size-3" />
|
||||
<span class="ml-1">Approve</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => denyOne(a)}>
|
||||
<XIcon class="size-3" />
|
||||
<span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if pendingApprovals.length > 1}
|
||||
<div class="mt-1">
|
||||
<Button size="sm" variant="default" class="h-6 px-2 text-xs" disabled={approving !== null} onclick={approveAll}>
|
||||
<CheckIcon class="size-3" />
|
||||
<span class="ml-1">Approve all</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if $messages.length === 0}
|
||||
@@ -114,7 +187,6 @@
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
||||
<InlineApproval approvals={msg.pendingApprovals} />
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
|
||||
Reference in New Issue
Block a user