Files
oikos/web/src/lib/stores/chat.ts
dtoro 851b5dce67
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: master-detail entity sheet, freshness in Entities table, live logo
Problem: the web UI felt dead and hard to navigate — the Entities table
had no health/freshness signal (just a meaningless row-mutation
timestamp), no way to see what was actually monitoring an entity,
sessions couldn't be reopened, and every drill-down was a full page
navigation that lost the list.

Change:
- Entities table: Updated column replaced with a health dot + relative
  "checked Xm ago", sourced from the backend's new health/last_check_at
  fields.
- New EntityDetailContent.svelte extracted from EntityDetail.svelte and
  shared between the full #/entity/:slug page and a new EntitySheet.svelte
  opened from the Entities table (master-detail, row click opens a panel
  instead of navigating away). Adds a Monitoring card listing the
  entity's check_defs (kind, interval, enabled/disabled with
  click-to-toggle via the existing PatchCheck endpoint) and renders
  attributes as key/value pairs instead of raw JSON.
- Sessions: fixed a bug where clicking a session loaded it into the
  chat store but never navigated to the chat page, so nothing appeared
  to happen. Added a SessionRail inside Chat so switching sessions
  never leaves the chat surface.
- Fixed the local dev proxy (vite.config.ts): production Caddy strips
  the /agent prefix before forwarding to nomos; the dev proxy didn't,
  so every session/chat fetch 404'd locally while working in prod.
- Found and fixed a real latent bug while testing the session fix:
  chat.ts's loadSessionMessages passed the persisted tool_calls array
  straight through, but nomos stores the tool_use and tool_result as
  two entries sharing one id. Chat.svelte's keyed {#each tool (tool.id)}
  throws on the duplicate key, which silently blanked the entire
  message list — invisible until sessions were actually clickable.
  Fixed by merging tool_calls by id before rendering, matching the
  shape the live-streaming path already produces.
- UI polish: sidebar logo is now just the omicron mark in white (was
  icon+text in the accent color); removed the sheet overlay's
  backdrop-blur (distracting per feedback); the Attributes/Relations/
  Signals grids used viewport-based lg:/3xl: breakpoints, which forced
  multi-column layouts based on browser width regardless of the sheet's
  actual rendered width — switched to Tailwind v4 container queries
  (@lg:/@2xl:/@3xl:) so layout responds to the real available width in
  both the full page and the narrower sheet.

Risk: reversible_low (UI-only; no destructive operations; the tool_calls
merge and dev-proxy fix are corrections to broken paths, not behavior
changes to working ones).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings). Manually verified in the browser
preview against the live dev API: Entities table health column renders
correctly; clicking a row opens the EntitySheet with a populated
Monitoring card (16 checks for host:hubris, verified via psql that
check_defs.target_id links them correctly); clicking a session now
loads its full transcript inline (was blank before the tool_calls fix);
sheet has no blur and lays out single/multi-column correctly at the
sheet's actual width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 00:26:37 +02:00

180 lines
5.1 KiB
TypeScript

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)
}
// mergeToolCalls collapses a persisted tool_calls array into one entry per
// call id. Nomos persists the tool_use and tool_result as two separate
// entries sharing the same id (matching the SSE event pair); Chat.svelte
// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate
// keys and silently aborts the whole message list. Live-streamed messages
// never hit this because sendMessage() merges tool_result into the existing
// tool_use entry in place rather than appending a second one.
function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
const byId = new Map<string, ToolCallResult>()
for (const tc of raw ?? []) {
const key = tc.id ?? crypto.randomUUID()
const existing = byId.get(key)
byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key })
}
return Array.from(byId.values())
}
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: mergeToolCalls(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)
}
}