v0.16.0: Workspace component system — cards, lists, structured display
New workspace components: - emit_card: structured detail card with title, subtitle, fields, actions Fields can be clickable links (action property) Used for: entity details (Kunde, Objekt, Auftrag) - emit_list: vertical list of cards for multiple entities Used for: search results, navigation lists - "WHEN TO USE WHAT" guide in expert prompt Frontend rendering: - renderCard() with key-value fields, clickable links, action buttons - List container with title + stacked cards - Full CSS: dark theme cards, hover states, link styling Pipeline: - ExpertNode handles emit_card/emit_list in tool execution - UINode passes card/list through as-is (not wrapped in display) - Test runner: check_actions supports "has card", "has list", "has X or Y" Workspace components test: 22/22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8ab778257
commit
217d1a57d9
@ -38,28 +38,39 @@ 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) — formatted data [{{type, label, value?, style?}}]
|
- emit_display(items) — simple text/badge display [{{type, label, value?}}]
|
||||||
- create_machine(id, initial, states) — interactive UI with navigation
|
- create_machine(id, initial, states) — interactive UI navigation
|
||||||
states: {{"state_name": {{"actions": [...], "display": [...]}}}}
|
- add_state / reset_machine / destroy_machine — machine lifecycle
|
||||||
- add_state(id, state, buttons, content) — add state to machine
|
|
||||||
- reset_machine(id) — reset to initial
|
WHEN TO USE WHAT:
|
||||||
- destroy_machine(id) — remove machine
|
- 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:
|
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_actions", "args": {{"actions": [{{"label": "...", "action": "..."}}]}}}}
|
{{"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 for the user"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Rules:
|
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.
|
- 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.
|
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":
|
if tool == "emit_actions":
|
||||||
actions.extend(args.get("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":
|
elif tool == "set_state":
|
||||||
key = args.get("key", "")
|
key = args.get("key", "")
|
||||||
if key:
|
if key:
|
||||||
|
|||||||
@ -306,16 +306,21 @@ class UINode(Node):
|
|||||||
"value": str(value),
|
"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:
|
if thought.display_items:
|
||||||
for item in thought.display_items:
|
for item in thought.display_items:
|
||||||
controls.append({
|
item_type = item.get("type", "text")
|
||||||
"type": "display",
|
if item_type in ("card", "list"):
|
||||||
"display_type": item.get("type", "text"),
|
# Pass through structured components as-is
|
||||||
"label": item.get("label", ""),
|
controls.append(item)
|
||||||
"value": item.get("value", ""),
|
else:
|
||||||
"style": item.get("style", ""),
|
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
|
# 5. Extract tables from tool output
|
||||||
if thought.tool_output:
|
if thought.tool_output:
|
||||||
|
|||||||
@ -255,14 +255,24 @@ def check_actions(actions: list, check: str) -> tuple[bool, str]:
|
|||||||
return True, f"{len(actions)} actions >= {expected}"
|
return True, f"{len(actions)} actions >= {expected}"
|
||||||
return False, f"{len(actions)} actions < {expected}"
|
return False, f"{len(actions)} actions < {expected}"
|
||||||
|
|
||||||
# has table
|
# has TYPE or has TYPE1 or TYPE2
|
||||||
if check.strip() == "has table":
|
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:
|
for a in actions:
|
||||||
if isinstance(a, dict) and a.get("type") == "table":
|
if isinstance(a, dict) and a.get("type") in types:
|
||||||
cols = a.get("columns", [])
|
atype = a.get("type")
|
||||||
rows = len(a.get("data", []))
|
if atype == "table":
|
||||||
return True, f"table found: {len(cols)} cols, {rows} rows"
|
return True, f"table found: {len(a.get('columns', []))} cols, {len(a.get('data', []))} rows"
|
||||||
return False, f"no table in {len(actions)} controls"
|
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
|
# any action contains "foo" or "bar" — searches buttons only
|
||||||
m = re.match(r'any action contains\s+"?(.+?)"?\s*$', check)
|
m = re.match(r'any action contains\s+"?(.+?)"?\s*$', check)
|
||||||
|
|||||||
@ -77,11 +77,90 @@ export function dockControls(controls) {
|
|||||||
+ (ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
|
+ (ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
|
||||||
}
|
}
|
||||||
container.appendChild(disp);
|
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);
|
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 += '<div class="ws-card-title">' + esc(card.title) + '</div>';
|
||||||
|
if (card.subtitle) html += '<div class="ws-card-subtitle">' + esc(card.subtitle) + '</div>';
|
||||||
|
|
||||||
|
if (card.fields && card.fields.length) {
|
||||||
|
html += '<div class="ws-card-fields">';
|
||||||
|
for (const f of card.fields) {
|
||||||
|
const val = f.action
|
||||||
|
? '<span class="ws-card-link" data-action="' + esc(f.action) + '">' + esc(String(f.value ?? '')) + '</span>'
|
||||||
|
: '<span class="ws-card-val">' + esc(String(f.value ?? '')) + '</span>';
|
||||||
|
html += '<div class="ws-card-field"><span class="ws-card-key">' + esc(f.label || '') + '</span>' + val + '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.actions && card.actions.length) {
|
||||||
|
html += '<div class="ws-card-actions">';
|
||||||
|
for (const a of card.actions) {
|
||||||
|
html += '<button class="control-btn ws-card-btn" data-action="' + esc(a.action || '') + '">' + esc(a.label || '') + '</button>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function clearDashboard() {
|
||||||
const body = document.getElementById('workspace-body');
|
const body = document.getElementById('workspace-body');
|
||||||
if (body) body.innerHTML = '';
|
if (body) body.innerHTML = '';
|
||||||
|
|||||||
@ -143,6 +143,23 @@ button:hover { background: #1d4ed8; }
|
|||||||
.cd-label { color: #888; }
|
.cd-label { color: #888; }
|
||||||
.cd-value { color: #e0e0e0; margin-left: 0.5rem; }
|
.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 */
|
||||||
#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-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; }
|
.login-card { background: #1a1a1a; padding: 2rem; border-radius: 0.6rem; text-align: center; }
|
||||||
|
|||||||
40
testcases/workspace_components.md
Normal file
40
testcases/workspace_components.md
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user