- Thinker emits ACTIONS: JSON line with follow-up buttons
- UI node is now pure code (no LLM call) — renders actions as buttons,
extracts tables from pipe-separated tool output, labels for single values
- Controls only in workspace panel, not duplicated in chat
- Process cards only in awareness panel, failed auto-remove after 30s
- Auth expiry detection: 403/1006 shows login button, stops reconnect loop
- Sensor timezone fix: zoneinfo.ZoneInfo("Europe/Berlin") for proper DST
- Cache-Control: no-cache on index.html
- Markdown rendering in chat messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
"""UI Node: pure renderer — converts ThoughtResult actions + data into controls."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from .base import Node
|
|
from ..types import ThoughtResult
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
|
|
class UINode(Node):
|
|
name = "ui"
|
|
# No model — pure code, no LLM calls
|
|
|
|
def __init__(self, send_hud):
|
|
super().__init__(send_hud)
|
|
self.current_controls: list[dict] = []
|
|
|
|
def _extract_table(self, tool_output: str) -> dict | None:
|
|
"""Try to parse tabular data from tool output."""
|
|
if not tool_output:
|
|
return None
|
|
lines = [l.strip() for l in tool_output.strip().split("\n") if l.strip()]
|
|
if len(lines) < 2:
|
|
return None
|
|
|
|
# Detect pipe-separated tables (e.g. "col1 | col2\nval1 | val2")
|
|
if " | " in lines[0]:
|
|
columns = [c.strip() for c in lines[0].split(" | ")]
|
|
data = []
|
|
for line in lines[1:]:
|
|
if line.startswith("-") or line.startswith("="):
|
|
continue # separator line
|
|
vals = [v.strip() for v in line.split(" | ")]
|
|
if len(vals) == len(columns):
|
|
data.append(dict(zip(columns, vals)))
|
|
if data:
|
|
return {"type": "table", "columns": columns, "data": data}
|
|
|
|
# Detect "Table: X" header format from sqlite wrapper
|
|
if lines[0].startswith("Table:"):
|
|
table_name = lines[0].replace("Table:", "").strip()
|
|
if len(lines) >= 2 and " | " in lines[1]:
|
|
columns = [c.strip() for c in lines[1].split(" | ")]
|
|
data = []
|
|
for line in lines[2:]:
|
|
vals = [v.strip() for v in line.split(" | ")]
|
|
if len(vals) == len(columns):
|
|
data.append(dict(zip(columns, vals)))
|
|
if data:
|
|
return {"type": "table", "columns": columns, "data": data}
|
|
|
|
return None
|
|
|
|
async def process(self, thought: ThoughtResult, history: list[dict],
|
|
memory_context: str = "") -> list[dict]:
|
|
controls = []
|
|
|
|
# 1. Render actions from Thinker as buttons
|
|
for action in thought.actions:
|
|
controls.append({
|
|
"type": "button",
|
|
"label": action.get("label", "Action"),
|
|
"action": action.get("action", "unknown"),
|
|
"payload": action.get("payload", {}),
|
|
})
|
|
|
|
# 2. Extract tables from tool output
|
|
if thought.tool_output:
|
|
table = self._extract_table(thought.tool_output)
|
|
if table:
|
|
controls.append(table)
|
|
|
|
# 3. Add labels for key tool results (single-value outputs)
|
|
if thought.tool_used and thought.tool_output and not any(c["type"] == "table" for c in controls):
|
|
output = thought.tool_output.strip()
|
|
# Short single-line output → label
|
|
if "\n" not in output and len(output) < 100:
|
|
controls.append({
|
|
"type": "label",
|
|
"id": "tool_result",
|
|
"text": thought.tool_used,
|
|
"value": output,
|
|
})
|
|
|
|
if controls:
|
|
self.current_controls = controls
|
|
await self.hud("controls", controls=controls)
|
|
log.info(f"[ui] emitting {len(controls)} controls")
|
|
else:
|
|
if self.current_controls:
|
|
# Keep previous controls visible
|
|
controls = self.current_controls
|
|
await self.hud("decided", instruction="no new controls")
|
|
|
|
return controls
|