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

0
web/dist/.gitkeep vendored Normal file
View File

21
web/embed.go Normal file
View File

@@ -0,0 +1,21 @@
// Package web embeds the compiled control-room SPA (web/dist) into the oikos
// binary, preserving the single-binary deployment (ADR-0001). The dist tree is
// produced by `npm run build` (or the Docker ui-builder stage); a committed
// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not
// been built.
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// DistFS returns the built SPA rooted at dist/. When the UI has not been built
// (only the .gitkeep placeholder is present), Open("index.html") will fail and
// the caller serves a 404 — the binary still starts.
func DistFS() (fs.FS, error) {
return fs.Sub(dist, "dist")
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oikos — Control Room</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1438
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
web/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "oikos-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && touch dist/.gitkeep",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tsconfig/svelte": "^5.0.0",
"svelte": "^5.0.0",
"typescript": "^5.5.0",
"vite": "^6.0.0"
}
}

155
web/src/App.svelte Normal file
View File

@@ -0,0 +1,155 @@
<script lang="ts">
import Chat from '$lib/../pages/Chat.svelte'
import Sessions from '$lib/../pages/Sessions.svelte'
import { newChat } from '$lib/stores/chat'
import { onMount } from 'svelte'
import { slide } from 'svelte/transition'
let page = $state('chat')
let drawerOpen = $state(false)
onMount(() => {
function sync() {
page = location.hash.slice(2) || 'chat'
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
})
function navigate(p: string) {
location.hash = '#/' + p
}
</script>
<div class="app">
<nav class="sidebar">
<div class="logo">Oikos</div>
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
<span class="nav-icon"></span>
<span>New</span>
</button>
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
<span class="nav-icon">💬</span>
<span>Chat</span>
</button>
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
<span class="nav-icon">📋</span>
<span>Sessions</span>
</button>
<div class="spacer"></div>
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
Chat {drawerOpen ? '▼' : '▲'}
</button>
</nav>
<main class="main">
{#if page === 'chat'}
<Chat />
{:else if page === 'sessions'}
<Sessions />
{:else}
<Chat />
{/if}
</main>
{#if drawerOpen}
<aside class="drawer" transition:slide={{ axis: 'x' }}>
<Chat />
</aside>
{/if}
</div>
<style>
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 56px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0;
gap: 0.5rem;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-blue);
margin-bottom: 0.5rem;
user-select: none;
}
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.5rem;
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
border-radius: 6px;
width: 44px;
transition: background 0.15s, color 0.15s;
}
.nav-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.nav-btn.active {
background: var(--bg-active);
color: var(--accent-blue);
}
.nav-icon {
font-size: 1rem;
}
.spacer {
flex: 1;
}
.drawer-toggle {
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
padding: 0.5rem;
}
.drawer-toggle:hover {
color: var(--text);
}
.main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.drawer {
width: 380px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

62
web/src/app.css Normal file
View File

@@ -0,0 +1,62 @@
:root {
--bg: #0d1117;
--bg-surface: #161b22;
--bg-deeper: #0a0e13;
--bg-hover: #21262d;
--bg-active: #292e36;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-orange: #d29922;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100%;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
a {
color: var(--accent-blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

92
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,92 @@
const BASE = '/agent'
export interface Session {
id: string
title: string
actor: string
created_at: string
last_active_at: string
}
export interface Message {
id: string
session_id: string
role: string
content: any
created_at: string
}
export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${BASE}/sessions`)
if (!res.ok) return []
const data = await res.json()
return data.sessions ?? []
}
export async function fetchMessages(sessionId: string): Promise<Message[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}`)
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export interface ChatEvent {
type: string
data: any
session_id?: string
iteration?: number
}
export function streamChat(
message: string,
sessionId: string | null,
onEvent: (ev: ChatEvent) => void,
onError: (err: string) => void,
onDone: () => void
): AbortController {
const controller = new AbortController()
fetch(`${BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
signal: controller.signal
}).then(async (res) => {
if (!res.ok) {
onError(`HTTP ${res.status}`)
return
}
const reader = res.body?.getReader()
if (!reader) {
onError('no response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const ev: ChatEvent = JSON.parse(line.slice(6))
onEvent(ev)
} catch {
// skip malformed
}
}
}
}
}).catch((err) => {
onError(err.message)
}).finally(() => {
onDone()
})
return controller
}

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)
}
}

6
web/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
const app = mount(App, { target: document.getElementById('app')! })
export default app

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>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat'
import { onMount } from 'svelte'
onMount(() => {
loadSessions()
})
</script>
<div class="sessions-page">
<h2>Sessions</h2>
<div class="session-list">
{#each $sessions as session (session.id)}
<button
class="session-card"
class:active={$currentSession === session.id}
onclick={() => loadSessionMessages(session.id)}
>
<div class="session-title">{session.title || 'Untitled'}</div>
<div class="session-meta">
{new Date(session.last_active_at).toLocaleString()}
</div>
</button>
{:else}
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
{/each}
</div>
</div>
<style>
.sessions-page {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
}
h2 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text);
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
text-align: left;
transition: border-color 0.15s;
}
.session-card:hover {
border-color: var(--accent-blue);
}
.session-card.active {
border-color: var(--accent-blue);
background: var(--bg-hover);
}
.session-title {
font-weight: 500;
}
.session-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.empty {
color: var(--text-muted);
font-size: 0.875rem;
padding: 2rem 0;
text-align: center;
}
</style>

15
web/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"paths": {
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

20
web/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [svelte()],
base: '/ui/',
resolve: {
alias: { $lib: '/src/lib' }
},
build: {
outDir: 'dist',
emptyOutDir: true
},
server: {
proxy: {
'/api': 'http://localhost:8090',
'/agent': 'http://localhost:8092'
}
}
})