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 += '
' + esc(card.title) + '
'; + if (card.subtitle) html += '
' + esc(card.subtitle) + '
'; + + if (card.fields && card.fields.length) { + html += '
'; + for (const f of card.fields) { + const val = f.action + ? '' + esc(String(f.value ?? '')) + '' + : '' + esc(String(f.value ?? '')) + ''; + html += '
' + esc(f.label || '') + '' + val + '
'; + } + html += '
'; + } + + if (card.actions && card.actions.length) { + html += '
'; + for (const a of card.actions) { + html += ''; + } + html += '
'; + } + + el.innerHTML = html; + + // Wire up field links and action buttons + el.querySelectorAll('.ws-card-link').forEach(link => { + link.onclick = (e) => { + e.stopPropagation(); + const action = link.dataset.action; + if (_ws && _ws.readyState === 1) { + _ws.send(JSON.stringify({ type: 'action', action, data: {} })); + addTrace('runtime', 'action', action); + } + }; + }); + el.querySelectorAll('.ws-card-btn').forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const action = btn.dataset.action; + if (_ws && _ws.readyState === 1) { + _ws.send(JSON.stringify({ type: 'action', action, data: {} })); + addTrace('runtime', 'action', action); + } + }; + }); + + return el; +} + export function clearDashboard() { const body = document.getElementById('workspace-body'); if (body) body.innerHTML = ''; diff --git a/static/style.css b/static/style.css index 22bdcd6..7466be3 100644 --- a/static/style.css +++ b/static/style.css @@ -143,6 +143,23 @@ button:hover { background: #1d4ed8; } .cd-label { color: #888; } .cd-value { color: #e0e0e0; margin-left: 0.5rem; } +/* Workspace cards */ +.ws-card { background: #111; border: 1px solid #222; border-radius: 0.4rem; padding: 0.5rem 0.6rem; width: 100%; } +.ws-card-clickable { cursor: pointer; } +.ws-card-clickable:hover { border-color: #2563eb; background: #0a1628; } +.ws-card-title { font-size: 0.85rem; font-weight: 700; color: #e0e0e0; } +.ws-card-subtitle { font-size: 0.7rem; color: #888; margin-top: 0.1rem; } +.ws-card-fields { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.15rem; } +.ws-card-field { display: flex; justify-content: space-between; font-size: 0.75rem; padding: 0.1rem 0; } +.ws-card-key { color: #888; } +.ws-card-val { color: #e0e0e0; font-weight: 500; } +.ws-card-link { color: #60a5fa; cursor: pointer; font-weight: 500; } +.ws-card-link:hover { text-decoration: underline; } +.ws-card-actions { margin-top: 0.4rem; display: flex; gap: 0.3rem; flex-wrap: wrap; } +.ws-card-btn { font-size: 0.7rem; padding: 0.2rem 0.5rem; } +.ws-list { display: flex; flex-direction: column; gap: 0.3rem; width: 100%; } +.ws-list-title { font-size: 0.75rem; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 0.2rem; } + /* Login overlay */ #login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 1000; } .login-card { background: #1a1a1a; padding: 2rem; border-radius: 0.6rem; text-align: center; } diff --git a/testcases/workspace_components.md b/testcases/workspace_components.md new file mode 100644 index 0000000..ebaea98 --- /dev/null +++ b/testcases/workspace_components.md @@ -0,0 +1,40 @@ +# Workspace Components + +Tests that the expert emits structured UI components (cards, lists, tables) +instead of dumping text or raw SQL. The workspace should show domain-aware displays. + +## Setup +- clear history + +## Steps + +### 1. Detail card for a single entity +- send: zeig mir Details zu Kunde 2 +- expect_trace: has tool_call +- expect_actions: has card +- expect_response: not contains "SELECT" or "JOIN" +- expect_response: length > 10 + +### 2. List of items with navigation +- send: zeig mir alle Objekte von Kunde 2 +- expect_trace: has tool_call +- expect_actions: has card or has table +- expect_response: length > 10 + +### 3. Table for tabular data +- send: zeig mir die Geraete von Objekt 4 +- expect_trace: has tool_call +- expect_actions: has table +- expect_response: length > 10 + +### 4. Card with actions (drill-down buttons) +- send: zeig mir Auftrag 21479 +- expect_trace: has tool_call +- expect_actions: length >= 1 +- expect_response: length > 10 + +### 5. Summary card with key metrics +- send: gib mir eine Zusammenfassung von Objekt 4 +- expect_trace: has tool_call +- expect_actions: has card +- expect_response: length > 20