Nico acc0dff4e5 v0.9.9: deterministic UI — Thinker declares actions, UI renders without LLM
- 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>
2026-03-28 15:06:24 +01:00

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