feat(chat): MCP tool apps — custom inline renderers for 12 tools
12 of 33 MCP tools now render as rich inline cards instead of raw JSON: EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults, BlastRadius, ChangeLog, FleetSnapshot, MetricChart. Architecture: - Server: annotateJSONResult() wraps queryRows with __renderer hints - Registry: match/dispatch system maps tool names to Svelte components - Chat: inline dispatch with 5-card limit, overflow to collapsed group - ToolCallGroup: unmatched prop, hides when all matched, ARIA labels Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
This commit is contained in:
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const grouped = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const g: Record<number, string[]> = {}
|
||||
for (const r of rows) {
|
||||
const d = Number(r.depth) || 0
|
||||
if (!g[d]) g[d] = []
|
||||
g[d].push(r.slug)
|
||||
}
|
||||
return g
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const total = $derived(rows?.length ?? 0)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="animate-pulse text-muted-foreground">calculating…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if grouped && total > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-1 font-medium text-muted-foreground">
|
||||
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each slugs as slug}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-muted-foreground">no affected entities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user