From 09374674e383dfd59b81ed76e5a6de38c2a29cce Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 29 Mar 2026 21:08:13 +0200 Subject: [PATCH] v0.16.1: Response-step card generation, history restore, graph animation mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card generation moved to response step: - Response LLM outputs JSON with "text" + optional "card" - Cards use actual query data, not placeholder templates - Plan step no longer includes emit_card (avoids {{template}} syntax) - Fallback: raw text response if JSON parse fails History restore on reconnect: - Frontend fetches /api/history on WS connect - Renders last 20 messages in chat panel - Only restores if chat is empty (fresh load) Graph animation: - Dynamic node name → graph ID mapping from graph definition - All nodes (including eras_expert) pulse correctly - 200ms animation queue prevents bulk event overlap Co-Authored-By: Claude Opus 4.6 (1M context) --- agent/nodes/expert_base.py | 70 ++++++++++++++++++++++++-------------- static/js/graph.js | 32 ++++++++++------- static/js/ws.js | 28 ++++++++++++++- 3 files changed, 91 insertions(+), 39 deletions(-) diff --git a/agent/nodes/expert_base.py b/agent/nodes/expert_base.py index b0fa66d..36ef7cd 100644 --- a/agent/nodes/expert_base.py +++ b/agent/nodes/expert_base.py @@ -38,39 +38,28 @@ Given a job description, produce a JSON tool sequence to accomplish it. Available tools: - query_db(query, database) — SQL SELECT/DESCRIBE/SHOW only -- emit_card(card) — show a detail card on the workspace: - {{"title": "...", "subtitle": "...", "fields": [{{"label": "Kunde", "value": "Mahnke GmbH", "action": "show_kunde_42"}}], "actions": [{{"label": "Geraete zeigen", "action": "show_geraete"}}]}} - Use for: single entity details, summaries, overviews. - Fields with "action" become clickable links. -- emit_list(list) — show a list of cards: - {{"title": "Auftraege morgen", "items": [{{"title": "21479", "subtitle": "Mahnke - Goetheplatz 7", "fields": [{{"label":"Typ","value":"Ablesung"}}], "action": "show_auftrag_21479"}}]}} - Use for: multiple entities, search results, navigation lists. - emit_actions(actions) — show buttons [{{label, action, payload?}}] - set_state(key, value) — persistent key-value -- emit_display(items) — simple text/badge display [{{type, label, value?}}] - create_machine(id, initial, states) — interactive UI navigation - add_state / reset_machine / destroy_machine — machine lifecycle -WHEN TO USE WHAT: -- Single entity detail (Kunde, Objekt, Auftrag) → emit_card -- Multiple entities (list of Objekte, Auftraege) → emit_list (few items) or query_db with table (many rows) -- Tabular data (Geraete, Verbraeuche) → query_db (renders as table automatically) -- User choices / next steps → emit_actions (buttons) +NOTE: Cards are generated automatically in the response step from query results. +Do NOT plan emit_card or emit_list — just query the data and the system handles display. Output ONLY valid JSON: {{ "tool_sequence": [ - {{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}}, - {{"tool": "emit_card", "args": {{"card": {{"title": "...", "fields": [...], "actions": [...]}}}}}} + {{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}} ], - "response_hint": "How to phrase the result for the user" + "response_hint": "How to phrase the result" }} Rules: - NEVER guess column names. Use ONLY columns from the schema. - Max 5 tools. Keep it focused. -- The job is self-contained — all context you need is in the job description. -- Prefer emit_card for entity details over raw text.""" +- For entity details: query all relevant fields, the response step creates the card. +- For lists: query multiple rows, the table renders automatically. +- The job is self-contained.""" RESPONSE_SYSTEM = """You are a domain expert summarizing results for the user. @@ -79,10 +68,25 @@ Rules: Job: {job} {results} -Write a concise, natural response. 1-3 sentences. -- Reference specific data from the results. -- Don't repeat raw output — summarize. -- Match the language: {language}.""" +Output a JSON object with "text" (response to user) and optionally "card" (structured display): + +{{ + "text": "Concise natural response, 1-3 sentences. Reference data. Match language: {language}.", + "card": {{ + "title": "Entity Name or ID", + "subtitle": "Type or category", + "fields": [{{"label": "Field", "value": "actual value from results"}}], + "actions": [{{"label": "Next action", "action": "action_id"}}] + }} +}} + +Rules: +- "text" is REQUIRED. Keep it short. +- "card" is OPTIONAL. Include it for single-entity details (Kunde, Objekt, Auftrag). +- Card fields must use ACTUAL values from the query results, never templates/placeholders. +- For lists of multiple entities, use multiple fields or skip the card. +- If no card makes sense, just return {{"text": "..."}}. +- Output ONLY valid JSON.""" def __init__(self, send_hud, process_manager=None): super().__init__(send_hud) @@ -214,9 +218,25 @@ Write a concise, natural response. 1-3 sentences. domain=self.DOMAIN_SYSTEM, job=job, results=results_text, language=language)}, {"role": "user", "content": job}, ] - response = await llm_call(self.model, resp_messages) - if not response: - response = "[no response]" + raw_response = await llm_call(self.model, resp_messages) + + # Parse JSON response with optional card + response = raw_response or "[no response]" + try: + text = raw_response.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3] + text = text.strip() + resp_data = json.loads(text) + response = resp_data.get("text", raw_response) + if resp_data.get("card"): + card = resp_data["card"] + card["type"] = "card" + display_items.append(card) + except (json.JSONDecodeError, Exception): + pass # Use raw response as text await self.hud("done", response=response[:100]) diff --git a/static/js/graph.js b/static/js/graph.js index 2b47253..05f3299 100644 --- a/static/js/graph.js +++ b/static/js/graph.js @@ -4,6 +4,9 @@ import { initNodesFromGraph } from './awareness.js'; let cy = null; let _dragEnabled = true; +// Maps HUD node names → graph node IDs (built from graph definition) +// e.g. {"eras_expert": "expert_eras", "pa_v1": "pa", "thinker_v2": "thinker"} +let _nodeNameToId = {}; let _physicsRunning = false; let _physicsLayout = null; let _colaSpacing = 25; @@ -93,6 +96,12 @@ export async function initGraph() { const graph = await resp.json(); graphElements = buildGraphElements(graph, mx, cw, mid, row1, row2); initNodesFromGraph(graph); + // Build HUD name → graph ID mapping: {impl_name: role} + _nodeNameToId = {}; + for (const [role, impl] of Object.entries(graph.nodes || {})) { + _nodeNameToId[impl] = role; // "eras_expert" → "expert_eras" + _nodeNameToId[role] = role; // "expert_eras" → "expert_eras" + } } } catch (e) {} @@ -188,29 +197,26 @@ function flashEdge(sourceId, targetId) { export function graphAnimate(event, node) { if (!cy) return; - // Queue the animation instead of executing immediately + // Resolve HUD node name to graph ID (e.g. "eras_expert" → "expert_eras") + const graphId = _nodeNameToId[node] || node; _enqueue(() => { - if (node && cy.getElementById(node).length) pulseNode(node); + if (graphId && cy.getElementById(graphId).length) pulseNode(graphId); switch (event) { case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break; case 'decided': - if (node === 'director_v2' || node === 'director' || node === 'pa_v1') { - pulseNode(node); flashEdge(node, 'thinker'); - } else { - pulseNode(node || 'thinker'); flashEdge('thinker', 'output'); - } + pulseNode(graphId); flashEdge(graphId, 'output'); break; - case 'routed': pulseNode('pa'); break; + case 'routed': pulseNode(_nodeNameToId['pa_v1'] || 'pa'); break; case 'reflex_path': pulseNode('input'); flashEdge('input', 'output'); break; - case 'streaming': if (node === 'output') pulseNode('output'); break; + case 'streaming': if (graphId === 'output') pulseNode('output'); break; case 'controls': case 'machine_created': case 'machine_transition': pulseNode('ui'); break; case 'updated': pulseNode('memorizer'); flashEdge('output', 'memorizer'); break; - case 'tool_call': pulseNode(node || 'thinker'); break; - case 'tool_result': - if (cy.getElementById('interpreter').length) pulseNode('interpreter'); break; - case 'thinking': if (node) pulseNode(node); break; + case 'tool_call': pulseNode(graphId); break; + case 'tool_result': pulseNode(graphId); break; + case 'thinking': pulseNode(graphId); break; + case 'planned': pulseNode(graphId); break; case 'tick': pulseNode('sensor'); break; } }); // end _enqueue diff --git a/static/js/ws.js b/static/js/ws.js index 491cbe0..b2cfd06 100644 --- a/static/js/ws.js +++ b/static/js/ws.js @@ -2,7 +2,7 @@ import { authToken, isAuthFailed, setAuthFailed, showLogin } from './auth.js'; import { addTrace } from './trace.js'; -import { handleDelta, handleDone, setWs as setChatWs } from './chat.js'; +import { addMsg, handleDelta, handleDone, setWs as setChatWs } from './chat.js'; import { dockControls, setWs as setDashWs } from './dashboard.js'; import { graphAnimate } from './graph.js'; import { updateMeter, updateNodeFromHud, updateAwarenessState, updateAwarenessSensors } from './awareness.js'; @@ -30,6 +30,7 @@ export function connect() { setChatWs(ws); setDashWs(ws); connectDebugSockets(); + restoreHistory(); }; ws.onerror = () => {}; @@ -68,6 +69,31 @@ export function connect() { }; } +async function restoreHistory() { + try { + const headers = {}; + if (authToken) headers['Authorization'] = 'Bearer ' + authToken; + const r = await fetch('/api/history?last=20', { headers }); + if (!r.ok) return; + const data = await r.json(); + const messages = data.messages || []; + if (!messages.length) return; + // Only restore if chat is empty (fresh load) + if (document.getElementById('messages').children.length > 0) return; + for (const msg of messages) { + const el = addMsg(msg.role, ''); + if (msg.role === 'assistant') { + // Render as markdown + const { renderMarkdown } = await import('./util.js'); + el.innerHTML = renderMarkdown(msg.content || ''); + } else { + el.textContent = msg.content || ''; + } + } + addTrace('runtime', 'restored', `${messages.length} messages`); + } catch (e) {} +} + function connectDebugSockets() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const base = proto + '//' + location.host;