nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Agent (cmd/nomos):
- Stream LLM tokens via NewStreaming; emit text_delta then final text.
- OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters;
  NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix.
- Multi-turn: reload session history into context; UI passes session id.
- Fix agent_activity logging (agent_id/session_id) and mcpClient data race.

Events (live control-room feed):
- approval.created (mcp), approval.decided (api), execution.completed/failed
  (approved-action path), signal.raised/resolved + health.changed (scheduler,
  transition-gated).

Fixes:
- createApproval FK violation (reuse execution entity) — the agent's only
  write path; log the previously-swallowed errors.

Web UI:
- Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into
  the Go stage; committed .gitkeep placeholder keeps backend-only builds green.
- Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent
  same-origin in production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:22:27 +02:00
parent 2b3aa248b1
commit e8e230b4a5
34 changed files with 3267 additions and 134 deletions

263
web/src/pages/Chat.svelte Normal file
View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import { fly } from 'svelte/transition'
let input = ''
let messagesEnd: HTMLDivElement
$: $messages, $streaming, setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.trim()
if (!text || $streaming) return
input = ''
sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
</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>
{#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>
{/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>
{/each}
<div bind:this={messagesEnd}></div>
</div>
{#if $error}
<div class="error-banner" transition:fly={{ y: 8, duration: 200 }}>
{$error}
</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;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.role {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.text {
background: var(--bg-surface);
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;
}
.input-bar textarea:focus {
border-color: var(--accent-blue);
}
.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;
}
.input-bar button:disabled {
opacity: 0.3;
cursor: default;
}
.input-bar button.stop {
background: var(--accent-red);
}
</style>