feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user