v0.16.1: Response-step card generation, history restore, graph animation mapping

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) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-03-29 21:08:13 +02:00
parent 217d1a57d9
commit 09374674e3
3 changed files with 91 additions and 39 deletions

View File

@ -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])

View File

@ -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

View File

@ -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;