diff --git a/agent/nodes/expert_base.py b/agent/nodes/expert_base.py index 04f1955..b0fa66d 100644 --- a/agent/nodes/expert_base.py +++ b/agent/nodes/expert_base.py @@ -38,28 +38,39 @@ 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) — formatted data [{{type, label, value?, style?}}] -- create_machine(id, initial, states) — interactive UI with navigation - states: {{"state_name": {{"actions": [...], "display": [...]}}}} -- add_state(id, state, buttons, content) — add state to machine -- reset_machine(id) — reset to initial -- destroy_machine(id) — remove machine +- 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) Output ONLY valid JSON: {{ "tool_sequence": [ {{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "{database}"}}}}, - {{"tool": "emit_actions", "args": {{"actions": [{{"label": "...", "action": "..."}}]}}}} + {{"tool": "emit_card", "args": {{"card": {{"title": "...", "fields": [...], "actions": [...]}}}}}} ], "response_hint": "How to phrase the result for the user" }} Rules: -- NEVER guess column names. If unsure, DESCRIBE first. +- 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.""" +- The job is self-contained — all context you need is in the job description. +- Prefer emit_card for entity details over raw text.""" RESPONSE_SYSTEM = """You are a domain expert summarizing results for the user. @@ -125,6 +136,14 @@ Write a concise, natural response. 1-3 sentences. if tool == "emit_actions": actions.extend(args.get("actions", [])) + elif tool == "emit_card": + card = args.get("card", args) + card["type"] = "card" + display_items.append(card) + elif tool == "emit_list": + lst = args.get("list", args) + lst["type"] = "list" + display_items.append(lst) elif tool == "set_state": key = args.get("key", "") if key: diff --git a/agent/nodes/ui.py b/agent/nodes/ui.py index b34a8af..cd2e4fb 100644 --- a/agent/nodes/ui.py +++ b/agent/nodes/ui.py @@ -306,16 +306,21 @@ class UINode(Node): "value": str(value), }) - # 4. Add display items from Thinker's emit_display() calls + # 4. Add display items (cards, lists, or simple display) if thought.display_items: for item in thought.display_items: - controls.append({ - "type": "display", - "display_type": item.get("type", "text"), - "label": item.get("label", ""), - "value": item.get("value", ""), - "style": item.get("style", ""), - }) + item_type = item.get("type", "text") + if item_type in ("card", "list"): + # Pass through structured components as-is + controls.append(item) + else: + controls.append({ + "type": "display", + "display_type": item_type, + "label": item.get("label", ""), + "value": item.get("value", ""), + "style": item.get("style", ""), + }) # 5. Extract tables from tool output if thought.tool_output: diff --git a/runtime_test.py b/runtime_test.py index 655e3e3..b0b6c76 100644 --- a/runtime_test.py +++ b/runtime_test.py @@ -255,14 +255,24 @@ def check_actions(actions: list, check: str) -> tuple[bool, str]: return True, f"{len(actions)} actions >= {expected}" return False, f"{len(actions)} actions < {expected}" - # has table - if check.strip() == "has table": + # has TYPE or has TYPE1 or TYPE2 + m = re.match(r'has\s+(.+)', check) + if m: + types = [t.strip() for t in m.group(1).split(" or has ")] + # Also handle "card or has table" → ["card", "table"] + types = [t.replace("has ", "") for t in types] for a in actions: - if isinstance(a, dict) and a.get("type") == "table": - cols = a.get("columns", []) - rows = len(a.get("data", [])) - return True, f"table found: {len(cols)} cols, {rows} rows" - return False, f"no table in {len(actions)} controls" + if isinstance(a, dict) and a.get("type") in types: + atype = a.get("type") + if atype == "table": + return True, f"table found: {len(a.get('columns', []))} cols, {len(a.get('data', []))} rows" + elif atype == "card": + return True, f"card found: {a.get('title', '?')}, {len(a.get('fields', []))} fields" + elif atype == "list": + return True, f"list found: {a.get('title', '?')}, {len(a.get('items', []))} items" + else: + return True, f"{atype} found" + return False, f"no {' or '.join(types)} in {len(actions)} controls ({[a.get('type','?') for a in actions if isinstance(a, dict)]})" # any action contains "foo" or "bar" — searches buttons only m = re.match(r'any action contains\s+"?(.+?)"?\s*$', check) diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 43b8435..225240e 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -77,11 +77,90 @@ export function dockControls(controls) { + (ctrl.value ? '' + esc(String(ctrl.value)) + '' : ''); } container.appendChild(disp); + } else if (ctrl.type === 'card') { + container.appendChild(renderCard(ctrl)); + } else if (ctrl.type === 'list') { + const listEl = document.createElement('div'); + listEl.className = 'ws-list'; + if (ctrl.title) { + const h = document.createElement('div'); + h.className = 'ws-list-title'; + h.textContent = ctrl.title; + listEl.appendChild(h); + } + for (const item of (ctrl.items || [])) { + item.type = item.type || 'card'; + listEl.appendChild(renderCard(item)); + } + container.appendChild(listEl); } } body.appendChild(container); } +function renderCard(card) { + const el = document.createElement('div'); + el.className = 'ws-card'; + if (card.action) { + el.classList.add('ws-card-clickable'); + el.onclick = () => { + if (_ws && _ws.readyState === 1) { + _ws.send(JSON.stringify({ type: 'action', action: card.action, data: card.payload || {} })); + addTrace('runtime', 'action', card.action); + } + }; + } + + let html = ''; + if (card.title) html += '