nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
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:
162
web/src/lib/stores/chat.ts
Normal file
162
web/src/lib/stores/chat.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user