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:
parent
217d1a57d9
commit
09374674e3
@ -38,39 +38,28 @@ Given a job description, produce a JSON tool sequence to accomplish it.
|
|||||||
|
|
||||||
Available tools:
|
Available tools:
|
||||||
- query_db(query, database) — SQL SELECT/DESCRIBE/SHOW only
|
- 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?}}]
|
- emit_actions(actions) — show buttons [{{label, action, payload?}}]
|
||||||
- set_state(key, value) — persistent key-value
|
- 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
|
- create_machine(id, initial, states) — interactive UI navigation
|
||||||
- add_state / reset_machine / destroy_machine — machine lifecycle
|
- add_state / reset_machine / destroy_machine — machine lifecycle
|
||||||
|
|
||||||
WHEN TO USE WHAT:
|
NOTE: Cards are generated automatically in the response step from query results.
|
||||||
- Single entity detail (Kunde, Objekt, Auftrag) → emit_card
|
Do NOT plan emit_card or emit_list — just query the data and the system handles display.
|
||||||
- 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)
|
|
||||||
|
|
||||||
Output ONLY valid JSON:
|
Output ONLY valid JSON:
|
||||||
{{
|
{{
|
||||||
"tool_sequence": [
|
"tool_sequence": [
|
||||||
{{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}},
|
{{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}}
|
||||||
{{"tool": "emit_card", "args": {{"card": {{"title": "...", "fields": [...], "actions": [...]}}}}}}
|
|
||||||
],
|
],
|
||||||
"response_hint": "How to phrase the result for the user"
|
"response_hint": "How to phrase the result"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- NEVER guess column names. Use ONLY columns from the schema.
|
- NEVER guess column names. Use ONLY columns from the schema.
|
||||||
- Max 5 tools. Keep it focused.
|
- Max 5 tools. Keep it focused.
|
||||||
- The job is self-contained — all context you need is in the job description.
|
- For entity details: query all relevant fields, the response step creates the card.
|
||||||
- Prefer emit_card for entity details over raw text."""
|
- 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.
|
RESPONSE_SYSTEM = """You are a domain expert summarizing results for the user.
|
||||||
|
|
||||||
@ -79,10 +68,25 @@ Rules:
|
|||||||
Job: {job}
|
Job: {job}
|
||||||
{results}
|
{results}
|
||||||
|
|
||||||
Write a concise, natural response. 1-3 sentences.
|
Output a JSON object with "text" (response to user) and optionally "card" (structured display):
|
||||||
- Reference specific data from the results.
|
|
||||||
- Don't repeat raw output — summarize.
|
{{
|
||||||
- Match the language: {language}."""
|
"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):
|
def __init__(self, send_hud, process_manager=None):
|
||||||
super().__init__(send_hud)
|
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)},
|
domain=self.DOMAIN_SYSTEM, job=job, results=results_text, language=language)},
|
||||||
{"role": "user", "content": job},
|
{"role": "user", "content": job},
|
||||||
]
|
]
|
||||||
response = await llm_call(self.model, resp_messages)
|
raw_response = await llm_call(self.model, resp_messages)
|
||||||
if not response:
|
|
||||||
response = "[no response]"
|
# 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])
|
await self.hud("done", response=response[:100])
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { initNodesFromGraph } from './awareness.js';
|
|||||||
|
|
||||||
let cy = null;
|
let cy = null;
|
||||||
let _dragEnabled = true;
|
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 _physicsRunning = false;
|
||||||
let _physicsLayout = null;
|
let _physicsLayout = null;
|
||||||
let _colaSpacing = 25;
|
let _colaSpacing = 25;
|
||||||
@ -93,6 +96,12 @@ export async function initGraph() {
|
|||||||
const graph = await resp.json();
|
const graph = await resp.json();
|
||||||
graphElements = buildGraphElements(graph, mx, cw, mid, row1, row2);
|
graphElements = buildGraphElements(graph, mx, cw, mid, row1, row2);
|
||||||
initNodesFromGraph(graph);
|
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) {}
|
} catch (e) {}
|
||||||
|
|
||||||
@ -188,29 +197,26 @@ function flashEdge(sourceId, targetId) {
|
|||||||
|
|
||||||
export function graphAnimate(event, node) {
|
export function graphAnimate(event, node) {
|
||||||
if (!cy) return;
|
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(() => {
|
_enqueue(() => {
|
||||||
if (node && cy.getElementById(node).length) pulseNode(node);
|
if (graphId && cy.getElementById(graphId).length) pulseNode(graphId);
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break;
|
case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break;
|
||||||
case 'decided':
|
case 'decided':
|
||||||
if (node === 'director_v2' || node === 'director' || node === 'pa_v1') {
|
pulseNode(graphId); flashEdge(graphId, 'output');
|
||||||
pulseNode(node); flashEdge(node, 'thinker');
|
|
||||||
} else {
|
|
||||||
pulseNode(node || 'thinker'); flashEdge('thinker', 'output');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'routed': pulseNode('pa'); break;
|
case 'routed': pulseNode(_nodeNameToId['pa_v1'] || 'pa'); break;
|
||||||
case 'reflex_path': pulseNode('input'); flashEdge('input', 'output'); 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':
|
case 'controls': case 'machine_created': case 'machine_transition':
|
||||||
pulseNode('ui'); break;
|
pulseNode('ui'); break;
|
||||||
case 'updated': pulseNode('memorizer'); flashEdge('output', 'memorizer'); break;
|
case 'updated': pulseNode('memorizer'); flashEdge('output', 'memorizer'); break;
|
||||||
case 'tool_call': pulseNode(node || 'thinker'); break;
|
case 'tool_call': pulseNode(graphId); break;
|
||||||
case 'tool_result':
|
case 'tool_result': pulseNode(graphId); break;
|
||||||
if (cy.getElementById('interpreter').length) pulseNode('interpreter'); break;
|
case 'thinking': pulseNode(graphId); break;
|
||||||
case 'thinking': if (node) pulseNode(node); break;
|
case 'planned': pulseNode(graphId); break;
|
||||||
case 'tick': pulseNode('sensor'); break;
|
case 'tick': pulseNode('sensor'); break;
|
||||||
}
|
}
|
||||||
}); // end _enqueue
|
}); // end _enqueue
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { authToken, isAuthFailed, setAuthFailed, showLogin } from './auth.js';
|
import { authToken, isAuthFailed, setAuthFailed, showLogin } from './auth.js';
|
||||||
import { addTrace } from './trace.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 { dockControls, setWs as setDashWs } from './dashboard.js';
|
||||||
import { graphAnimate } from './graph.js';
|
import { graphAnimate } from './graph.js';
|
||||||
import { updateMeter, updateNodeFromHud, updateAwarenessState, updateAwarenessSensors } from './awareness.js';
|
import { updateMeter, updateNodeFromHud, updateAwarenessState, updateAwarenessSensors } from './awareness.js';
|
||||||
@ -30,6 +30,7 @@ export function connect() {
|
|||||||
setChatWs(ws);
|
setChatWs(ws);
|
||||||
setDashWs(ws);
|
setDashWs(ws);
|
||||||
connectDebugSockets();
|
connectDebugSockets();
|
||||||
|
restoreHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {};
|
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() {
|
function connectDebugSockets() {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const base = proto + '//' + location.host;
|
const base = proto + '//' + location.host;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user