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

162
web/src/lib/stores/chat.ts Normal file
View File

@@ -0,0 +1,162 @@
import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
text: string
tools: ToolCallResult[]
}
export interface ToolCallResult {
type: 'tool_use' | 'tool_result'
name: string
id?: string
args?: any
result?: any
error?: string
}
function mid(): string {
return crypto.randomUUID()
}
export const messages = writable<ChatMessage[]>([])
export const streaming = writable(false)
export const currentSession = writable<string | null>(null)
export const sessions = writable<Session[]>([])
export const sessionMessages = writable<Message[]>([])
export const error = writable<string | null>(null)
let activeController: AbortController | null = null
export async function loadSessions() {
const list = await fetchSessions()
sessions.set(list)
}
export async function loadSessionMessages(sessionId: string) {
currentSession.set(sessionId)
const msgs = await fetchMessages(sessionId)
sessionMessages.set(msgs)
const chatMsgs: ChatMessage[] = msgs.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
tools: m.content?.tool_calls ?? []
}))
messages.set(chatMsgs)
}
export function sendMessage(text: string) {
error.set(null)
streaming.set(true)
const userMsg: ChatMessage = {
id: mid(),
role: 'user',
text,
tools: []
}
messages.update((ms) => [...ms, userMsg])
const assistantMsg: ChatMessage = {
id: mid(),
role: 'assistant',
text: '',
tools: []
}
messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
activeController = streamChat(
text,
get(currentSession), // continue the active session so the agent keeps context
(ev: ChatEvent) => {
if (ev.type === 'session') {
currentSession.set(ev.data)
} else if (ev.type === 'tool_use') {
const tr: ToolCallResult = {
type: 'tool_use',
name: ev.data.name,
id: ev.data.id,
args: ev.data.args
}
activeTools.set(ev.data.id, tr)
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = [...last.tools, tr]
}
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = {
...existing,
type: 'tool_result',
result: ev.data.result,
error: ev.data.error
}
activeTools.set(ev.data.id, updated)
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) =>
t.id === ev.data.id ? updated : t
)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text += ev.data
}
return [...ms]
})
} else if (ev.type === 'text') {
// Final authoritative content for the turn; replaces accumulated deltas.
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text = ev.data
}
return [...ms]
})
} else if (ev.type === 'done') {
currentSession.set(ev.data?.session_id ?? ev.session_id)
} else if (ev.type === 'error') {
error.set(ev.data)
}
},
(err: string) => {
error.set(err)
},
() => {
streaming.set(false)
activeController = null
loadSessions()
}
)
}
export function newChat() {
cancelStream()
currentSession.set(null)
messages.set([])
error.set(null)
}
export function cancelStream() {
if (activeController) {
activeController.abort()
activeController = null
streaming.set(false)
}
}