feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Add three new pages completing the control-room web UI:
- Agent activity: polls /agent-activity every 5s, filterable by type/agent
- Knowledge search: FTS over /knowledge/search with snippet + entity links
- Audit trail: browseable audit log with actor/action/entity filters

Enhanced live events page with correlation-id clustering (Groups toggle).
Added fetchAgentActivity/searchKnowledge/fetchAudit to the API client.
11 nav items now cover all planned control-room views.
This commit is contained in:
2026-07-08 17:02:09 +02:00
parent cff05c0768
commit 2908b0a377
162 changed files with 7857 additions and 553 deletions

View File

@@ -1,13 +1,32 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import { fly } from 'svelte/transition'
import ContextRail from '$lib/components/ContextRail.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 WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let input = ''
let messagesEnd: HTMLDivElement
$: $messages, $streaming, setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
let { showRail = true }: { showRail?: boolean } = $props()
function handleSubmit(e: Event) {
e.preventDefault()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
$effect(() => {
void $messages
void $streaming
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
@@ -17,247 +36,202 @@
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
submit()
}
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
<div class="chat">
<div class="messages">
{#each $messages as msg (msg.id)}
<div class="message {msg.role}">
<div class="role">{msg.role === 'user' ? 'You' : 'Nomos'}</div>
{#if msg.text}
<div class="text">{msg.text}</div>
{/if}
{#each msg.tools as tool (tool.id)}
<div class="tool-chip" class:tool-use={tool.type === 'tool_use'} class:tool-result={tool.type === 'tool_result'}>
<div class="tool-header" transition:fly={{ y: 4, duration: 150 }}>
<span class="tool-icon">{tool.type === 'tool_use' ? '⚙' : '✓'}</span>
<span class="tool-name">{tool.name}</span>
<div class="flex h-full min-h-0">
<div class="flex min-w-0 flex-1 flex-col">
<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}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
{#if tool.type === 'tool_use' && tool.args}
<div class="tool-body">
<pre>{JSON.stringify(tool.args, null, 2)}</pre>
</div>
{/if}
{#if tool.type === 'tool_result'}
<div class="tool-body">
{#if tool.error}
<pre class="error">{tool.error}</pre>
{:else}
<pre>{JSON.stringify(tool.result, null, 2)}</pre>
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
</div>
{/if}
{#each $messages as msg (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#each msg.tools as tool (tool.id)}
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</summary>
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</details>
{/each}
{#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 -->
{@html render(msg.text)}
</div>
{:else if msg.tools.length === 0}
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current"></span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if !msg.text && msg.tools.length === 0 && msg.role === 'assistant'}
<div class="thinking">Thinking<span class="dots"></span></div>
{/if}
<div bind:this={messagesEnd}></div>
</div>
{/each}
<div bind:this={messagesEnd}></div>
</div>
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{$error}
</div>
</div>
{/if}
<div class="border-t bg-card/50 p-3">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if $error}
<div class="error-banner" transition:fly={{ y: 8, duration: 200 }}>
{$error}
{#if showRail}
<div class="hidden xl:block">
<ContextRail />
</div>
{/if}
<form class="input-bar" onsubmit={handleSubmit}>
<textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything..."
rows={2}
disabled={$streaming}
></textarea>
{#if $streaming}
<button type="button" class="stop" onclick={cancelStream}>■</button>
{:else}
<button type="submit" disabled={!input.trim()}>→</button>
{/if}
</form>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
max-width: 720px;
margin: 0 auto;
/* Minimal markdown styling for assistant messages. */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.message {
display: flex;
flex-direction: column;
gap: 0.25rem;
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.message.user {
align-items: flex-end;
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.message.assistant {
align-items: flex-start;
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.role {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.text {
background: var(--bg-surface);
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
max-width: 100%;
line-height: 1.5;
white-space: pre-wrap;
}
.thinking {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem;
}
.dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
}
.tool-chip {
margin-top: 0.25rem;
border-radius: 6px;
border: 1px solid var(--border);
overflow: hidden;
font-size: 0.8125rem;
width: 100%;
}
.tool-chip.tool-use {
border-left: 3px solid var(--accent-blue);
}
.tool-chip.tool-result {
border-left: 3px solid var(--accent-green);
}
.tool-header {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: var(--bg-surface);
}
.tool-icon {
font-size: 0.75rem;
}
.tool-name {
font-weight: 600;
font-family: var(--font-mono);
font-size: 0.8125rem;
}
.tool-body {
padding: 0.5rem;
background: var(--bg-deeper);
max-height: 200px;
overflow-y: auto;
}
.tool-body pre {
margin: 0;
font-family: var(--font-mono);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
}
.tool-body pre.error {
color: var(--accent-red);
}
.error-banner {
background: var(--accent-red);
color: white;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
text-align: center;
}
.input-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.input-bar textarea {
flex: 1;
background: var(--bg-deeper);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.625rem 0.75rem;
font-family: inherit;
font-size: 0.875rem;
resize: none;
outline: none;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.input-bar textarea:focus {
border-color: var(--accent-blue);
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.input-bar button {
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background: var(--accent-blue);
color: white;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
align-self: flex-end;
transition: opacity 0.15s;
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.input-bar button:disabled {
opacity: 0.3;
cursor: default;
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.input-bar button.stop {
background: var(--accent-red);
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>