feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

43
ARCHITECTURE_TODO.md Normal file
View file

@ -0,0 +1,43 @@
# Architecture TODO
This file tracks the current architecture cleanup work for the Nanobot web app.
## Card Platform
- [x] Move all template-backed cards to the dynamic `card.js` runtime.
- [x] Remove inline-script fallback execution from the frontend shell.
- [x] Keep the card runtime contract documented and stable.
- [x] Validate every template card automatically in CI/local checks.
- [x] Add lightweight fixture coverage for mount/update/destroy behavior.
## Template Source Of Truth
- [x] Treat `~/.nanobot/cards/templates` as the live source of truth.
- [x] Sync repo examples from the live template tree with a script instead of manual copying.
- [x] Make template drift a failing quality check.
## Backend Structure
- [x] Split card persistence/materialization out of `app.py`.
- [x] Split session helpers out of `app.py`.
- [x] Split Nanobot transport/client code out of `app.py`.
- [x] Keep HTTP routes thin and push domain logic into service modules.
## Frontend Structure
- [x] Split `useWebRTC.ts` into transport, sessions, cards/feed, and workbench modules.
- [x] Reduce `App.tsx` to layout/navigation concerns only.
- [x] Keep `CardFeed.tsx` focused on rendering, not domain mutation orchestration.
## Updates And Reactivity
- [x] Replace polling-heavy paths with evented updates where possible.
- [x] Keep card updates localized so a live sensor refresh does not churn the whole feed.
## Remaining Cleanup
- [x] Move backend startup state into FastAPI lifespan/app state.
- [x] Stop importing private underscore helpers across app boundaries.
- [x] Split the backend route surface into router modules.
- [x] Split the card runtime renderer into loader/host/renderer modules.
- [x] Replace async tool-job polling with an evented stream.

126
CARD_RUNTIME.md Normal file
View file

@ -0,0 +1,126 @@
# Card Runtime
The app shell is responsible for layout, navigation, sessions, feed ordering, and workbench placement.
Cards are responsible for their own UI and behavior through a small dynamic runtime contract.
## Source Of Truth
- Live card templates live under `~/.nanobot/cards/templates`.
- Repo examples under `examples/cards/templates` are mirrors for development/reference.
- New cards must be added as `manifest.json + template.html + card.js`.
- `template.html` is markup and styles only. Do not put executable `<script>` tags in it.
## Module Contract
Each template must export:
```js
export function mount({ root, state, host }) {
// render and wire up DOM
return {
update?.({ root, item, state, host }) {},
destroy?.() {},
};
}
```
`root`
- The card root element already contains the rendered `template.html`.
`state`
- The current `template_state` object for this card/workbench item.
`host`
- The runtime host API described below.
## Lifecycle
- `mount()` runs once when the card is attached.
- `update()` runs when the card item or `template_state` changes.
- `destroy()` must clean up timers, listeners, observers, and in-flight UI handlers.
Cards must not rely on:
- `document.currentScript`
- global `window.__nanobot*` helpers
- inline script re-execution
## Host API
State
- `host.getState()`
- `host.replaceState(nextState)`
- `host.patchState(patch)`
Card context
- `host.setLiveContent(snapshot)`
- `host.getLiveContent()`
- `host.setSelection(selection)`
- `host.getSelection()`
- `host.clearSelection()`
Refresh
- `host.setRefreshHandler(handler)`
- `host.runRefresh()`
- `host.requestFeedRefresh()`
Tools
- `host.callTool(name, args?)`
- `host.startToolCall(name, args?)`
- `host.getToolJob(jobId)`
- `host.callToolAsync(name, args?, options?)`
- `host.listTools()`
Utilities
- `host.renderMarkdown(markdown, { inline? })`
- `host.copyText(text)`
- `host.getThemeName()`
- `host.getThemeValue("--theme-card-neutral-text")`
## Theme Tokens
Cards should inherit theme from shared CSS variables instead of hardcoding app-level colors.
Use these families first:
- `--theme-card-neutral-*` for standard data cards
- `--theme-card-warm-*` for timeline/planning cards
- `--theme-card-success-*` for inbox/positive cards
- `--card-*` for feed shell cards
- `--helper-card-*` for helper cards
- `--theme-text*`, `--theme-border*`, `--theme-accent*`, `--theme-status-*` for generic UI
Examples:
```html
<div style="background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); border:1px solid var(--theme-card-neutral-border)">
```
```js
statusEl.style.color = "var(--theme-status-live)";
const accent = host.getThemeValue("--theme-accent");
```
## Rules
- Persist all card-local durable state through `replaceState()` / `patchState()`.
- Use `setLiveContent()` for transient model-readable card context.
- Register at most one refresh handler with `setRefreshHandler()`.
- Always clear timers/intervals in `destroy()`.
- Prefer standard DOM APIs and small helper functions over framework-specific assumptions.
- Prefer shared theme tokens over hardcoded shell/surface colors.
## Validation
Run:
```bash
python3 scripts/check_card_runtime.py
node scripts/check_card_runtime_fixture.mjs
python3 scripts/sync_card_templates.py --check
```
The runtime check enforces:
- `manifest.json`, `template.html`, and `card.js` exist
- `template.html` has no inline script
- `card.js` parses cleanly
- no legacy runtime globals remain
- the lifecycle fixture contract can mount, update, and destroy cleanly

50
THEMING.md Normal file
View file

@ -0,0 +1,50 @@
# Theming
The app theme is driven by shared CSS custom properties.
## Source Of Truth
- Theme presets live in `frontend/src/theme/themes.ts`.
- The selected theme is persisted in local storage under `nanobot.theme`.
- The active theme is applied to `document.documentElement` before the app renders.
## How To Change The App Theme
Edit or add a preset in `frontend/src/theme/themes.ts`:
- `label`
- `swatch`
- `tokens`
The picker in the conversation drawer reads directly from that file, so new presets appear automatically.
## Card Theming
Cards inherit the same variables as the app shell.
Prefer these semantic token families:
- `--theme-card-neutral-*`
- `--theme-card-warm-*`
- `--theme-card-success-*`
- `--card-*`
- `--helper-card-*`
- `--theme-text*`
- `--theme-border*`
- `--theme-accent*`
- `--theme-status-*`
Do not hardcode generic shell colors like `#ffffff`, `#111827`, or app-background gradients in new cards.
## Runtime Access
Dynamic cards can read theme values through the runtime host:
- `host.getThemeName()`
- `host.getThemeValue("--theme-accent")`
For values that should live-update when the theme changes, prefer CSS variables directly:
```html
<div style="color:var(--theme-card-neutral-text)"></div>
```

1232
app.py

File diff suppressed because it is too large Load diff

9
app_dependencies.py Normal file
View file

@ -0,0 +1,9 @@
from __future__ import annotations
from fastapi import Request
from web_runtime import WebAppRuntime
def get_runtime(request: Request) -> WebAppRuntime:
return request.app.state.runtime

488
card_store.py Normal file
View file

@ -0,0 +1,488 @@
from __future__ import annotations
import html
import json
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
NANOBOT_WORKSPACE = Path(os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))).expanduser()
CARDS_ROOT = NANOBOT_WORKSPACE / "cards"
CARD_INSTANCES_DIR = CARDS_ROOT / "instances"
CARD_TEMPLATES_DIR = CARDS_ROOT / "templates"
TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md"
MAX_TEMPLATES_IN_PROMPT = 12
MAX_TEMPLATE_HTML_CHARS = 4000
_INVALID_TEMPLATE_KEY_CHARS = re.compile(r"[^a-z0-9_-]+")
_CARD_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
_CARD_LANE_ORDER = {"attention": 0, "work": 1, "context": 2, "history": 3}
_CARD_STATE_ORDER = {"active": 0, "stale": 1, "resolved": 2, "superseded": 3, "archived": 4}
def ensure_card_store_dirs() -> None:
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
def _normalize_template_key(raw: str) -> str:
key = _INVALID_TEMPLATE_KEY_CHARS.sub("-", raw.strip().lower()).strip("-")
return key[:64]
def _normalize_card_id(raw: str) -> str:
card_id = raw.strip()
return card_id if _CARD_ID_PATTERN.fullmatch(card_id) else ""
def _card_instance_dir(card_id: str) -> Path | None:
card_id_clean = _normalize_card_id(card_id)
if not card_id_clean:
return None
return CARD_INSTANCES_DIR / card_id_clean
def _card_meta_path(card_id: str) -> Path | None:
instance_dir = _card_instance_dir(card_id)
if instance_dir is None:
return None
return instance_dir / "card.json"
def _card_state_path(card_id: str) -> Path | None:
instance_dir = _card_instance_dir(card_id)
if instance_dir is None:
return None
return instance_dir / "state.json"
def _parse_iso_datetime(raw: str) -> datetime | None:
value = raw.strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
card_id = _normalize_card_id(str(raw.get("id", "")))
if not card_id:
return None
kind = str(raw.get("kind", "text") or "text").strip().lower()
if kind not in {"text", "question"}:
kind = "text"
lane = str(raw.get("lane", "context") or "context").strip().lower()
if lane not in _CARD_LANE_ORDER:
lane = "context"
state = str(raw.get("state", "active") or "active").strip().lower()
if state not in _CARD_STATE_ORDER:
state = "active"
try:
priority = int(raw.get("priority", 50))
except (TypeError, ValueError):
priority = 50
priority = max(0, min(priority, 100))
raw_choices = raw.get("choices", [])
choices = [str(choice) for choice in raw_choices] if isinstance(raw_choices, list) else []
raw_template_state = raw.get("template_state", {})
template_state = raw_template_state if isinstance(raw_template_state, dict) else {}
return {
"id": card_id,
"kind": kind,
"title": str(raw.get("title", "")),
"content": str(raw.get("content", "")),
"question": str(raw.get("question", "")),
"choices": choices,
"response_value": str(raw.get("response_value", "")),
"slot": str(raw.get("slot", "")),
"lane": lane,
"priority": priority,
"state": state,
"template_key": str(raw.get("template_key", "")),
"template_state": template_state,
"context_summary": str(raw.get("context_summary", "")),
"chat_id": str(raw.get("chat_id", "web") or "web"),
"snooze_until": str(raw.get("snooze_until", "") or ""),
"created_at": str(raw.get("created_at", "")),
"updated_at": str(raw.get("updated_at", "")),
}
def _json_script_text(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
def _template_dir(template_key: str) -> Path:
return CARD_TEMPLATES_DIR / template_key
def _template_html_path(template_key: str) -> Path:
return _template_dir(template_key) / "template.html"
def _template_meta_path(template_key: str) -> Path:
return _template_dir(template_key) / "manifest.json"
def _read_template_meta(template_key: str) -> dict[str, Any]:
meta_path = _template_meta_path(template_key)
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
return meta if isinstance(meta, dict) else {}
except Exception:
return {}
def _materialize_card_content(card: dict[str, Any]) -> str:
if card.get("kind") != "text":
return str(card.get("content", ""))
template_key = str(card.get("template_key", "")).strip()
if not template_key:
return str(card.get("content", ""))
html_path = _template_html_path(template_key)
try:
template_html = html_path.read_text(encoding="utf-8")
except Exception:
return (
'<div style="padding:16px;border:1px solid #fecaca;border-radius:12px;'
'background:#fef2f2;color:#991b1b;font:600 14px/1.4 system-ui,sans-serif;">'
f"Missing template: {html.escape(template_key)}"
"</div>"
)
state_payload = card.get("template_state", {})
if not isinstance(state_payload, dict):
state_payload = {}
card_id = html.escape(str(card.get("id", "")))
safe_template_key = html.escape(template_key)
return (
f'<div data-nanobot-card-root data-card-id="{card_id}" data-template-key="{safe_template_key}">'
f'<script type="application/json" data-card-state>{_json_script_text(state_payload)}</script>'
f"{template_html}"
"</div>"
)
def _load_card(card_id: str) -> dict[str, Any] | None:
meta_path = _card_meta_path(card_id)
if meta_path is None or not meta_path.exists():
return None
try:
raw = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return None
if not isinstance(raw, dict):
return None
state_path = _card_state_path(card_id)
if state_path is not None and state_path.exists():
try:
raw_state = json.loads(state_path.read_text(encoding="utf-8"))
except Exception:
raw_state = {}
if isinstance(raw_state, dict):
raw["template_state"] = raw_state
card = _coerce_card_record(raw)
if card is None:
return None
card["content"] = _materialize_card_content(card)
return card
def _write_card(card: dict[str, Any]) -> dict[str, Any] | None:
normalized = _coerce_card_record(card)
if normalized is None:
return None
now = datetime.now(timezone.utc).isoformat()
existing = _load_card(normalized["id"])
if existing is not None:
normalized["created_at"] = existing.get("created_at") or normalized.get("created_at") or now
else:
normalized["created_at"] = normalized.get("created_at") or now
normalized["updated_at"] = normalized.get("updated_at") or now
instance_dir = _card_instance_dir(normalized["id"])
meta_path = _card_meta_path(normalized["id"])
state_path = _card_state_path(normalized["id"])
if instance_dir is None or meta_path is None or state_path is None:
return None
instance_dir.mkdir(parents=True, exist_ok=True)
template_state = normalized.pop("template_state", {})
meta_path.write_text(
json.dumps(normalized, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if normalized["kind"] == "text":
state_path.write_text(
json.dumps(
template_state if isinstance(template_state, dict) else {},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
else:
state_path.unlink(missing_ok=True)
return _load_card(normalized["id"])
def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
cards,
key=lambda item: (
_CARD_LANE_ORDER.get(str(item.get("lane", "context")), 99),
_CARD_STATE_ORDER.get(str(item.get("state", "active")), 99),
-int(item.get("priority", 0) or 0),
str(item.get("updated_at", "")),
str(item.get("created_at", "")),
),
reverse=False,
)
def _load_cards(chat_id: str | None = None) -> list[dict[str, Any]]:
ensure_card_store_dirs()
cards: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
for instance_dir in CARD_INSTANCES_DIR.iterdir():
if not instance_dir.is_dir():
continue
card = _load_card(instance_dir.name)
if card is None:
continue
if chat_id is not None and str(card.get("chat_id", "web") or "web") != chat_id:
continue
if card.get("state") == "archived":
continue
snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or ""))
if snooze_until is not None and snooze_until > now:
continue
cards.append(card)
return _sort_cards(cards)
def _find_card_by_slot(slot: str, *, chat_id: str) -> dict[str, Any] | None:
target_slot = slot.strip()
if not target_slot:
return None
for card in _load_cards():
if str(card.get("chat_id", "")).strip() != chat_id:
continue
if str(card.get("slot", "")).strip() == target_slot:
return card
return None
def _persist_card(card: dict[str, Any]) -> dict[str, Any] | None:
normalized = _coerce_card_record(card)
if normalized is None:
return None
existing_same_slot = None
slot = str(normalized.get("slot", "")).strip()
chat_id = str(normalized.get("chat_id", "web") or "web")
if slot:
existing_same_slot = _find_card_by_slot(slot, chat_id=chat_id)
if existing_same_slot is not None and existing_same_slot["id"] != normalized["id"]:
superseded = dict(existing_same_slot)
superseded["state"] = "superseded"
superseded["updated_at"] = datetime.now(timezone.utc).isoformat()
_write_card(superseded)
return _write_card(normalized)
def _delete_card(card_id: str) -> bool:
instance_dir = _card_instance_dir(card_id)
if instance_dir is None or not instance_dir.exists():
return False
shutil.rmtree(instance_dir, ignore_errors=True)
return True
def _delete_cards_for_chat(chat_id: str) -> int:
ensure_card_store_dirs()
removed = 0
for instance_dir in CARD_INSTANCES_DIR.iterdir():
if not instance_dir.is_dir():
continue
card = _load_card(instance_dir.name)
if card is None:
continue
if str(card.get("chat_id", "web") or "web") != chat_id:
continue
if _delete_card(instance_dir.name):
removed += 1
return removed
def _list_templates(limit: int | None = None) -> list[dict[str, Any]]:
ensure_card_store_dirs()
templates: list[dict[str, Any]] = []
for template_dir in CARD_TEMPLATES_DIR.iterdir():
if not template_dir.is_dir():
continue
key = _normalize_template_key(template_dir.name)
if not key:
continue
html_path = _template_html_path(key)
if not html_path.exists():
continue
try:
content = html_path.read_text(encoding="utf-8")
except Exception:
continue
stat = html_path.stat()
meta = _read_template_meta(key)
if bool(meta.get("deprecated")):
continue
created_at = str(
meta.get("created_at")
or datetime.fromtimestamp(stat.st_ctime, timezone.utc).isoformat()
)
updated_at = str(
meta.get("updated_at")
or datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
)
templates.append(
{
"id": key,
"key": key,
"title": str(meta.get("title", "")),
"content": content,
"notes": str(meta.get("notes", "")),
"example_state": meta.get("example_state", {}),
"created_at": created_at,
"updated_at": updated_at,
"file_url": f"/card-templates/{key}/template.html",
}
)
templates.sort(key=lambda item: item["updated_at"], reverse=True)
if limit is not None:
return templates[: max(0, limit)]
return templates
def _render_templates_markdown(rows: list[dict[str, Any]]) -> str:
lines = [
"# Card Templates",
"",
"These are user-approved template layouts for `mcp_display_render_card` cards.",
"Each card instance should provide a `template_key` and a `template_state` JSON object.",
"Use a matching template when the request intent fits.",
"Do not rewrite the HTML layout when an existing template already fits; fill the template_state instead.",
"",
]
for row in rows:
key = str(row.get("key", "")).strip() or "unnamed"
title = str(row.get("title", "")).strip() or "(untitled)"
notes = str(row.get("notes", "")).strip() or "(no usage notes)"
content = str(row.get("content", "")).strip()
example_state = row.get("example_state", {})
if len(content) > MAX_TEMPLATE_HTML_CHARS:
content = content[:MAX_TEMPLATE_HTML_CHARS] + "\n<!-- truncated -->"
html_lines = [f" {line}" for line in content.splitlines()] if content else [" "]
state_text = (
json.dumps(example_state, indent=2, ensure_ascii=False)
if isinstance(example_state, dict)
else "{}"
)
state_lines = [f" {line}" for line in state_text.splitlines()]
lines.extend(
[
f"## {key}",
f"- Title: {title}",
f"- Usage: {notes}",
"- Example State:",
*state_lines,
"- HTML:",
*html_lines,
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def _sync_templates_context_file() -> None:
try:
ensure_card_store_dirs()
rows = _list_templates(limit=MAX_TEMPLATES_IN_PROMPT)
if not rows:
TEMPLATES_CONTEXT_PATH.unlink(missing_ok=True)
return
TEMPLATES_CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True)
TEMPLATES_CONTEXT_PATH.write_text(_render_templates_markdown(rows), encoding="utf-8")
except Exception:
return
normalize_template_key = _normalize_template_key
normalize_card_id = _normalize_card_id
parse_iso_datetime = _parse_iso_datetime
coerce_card_record = _coerce_card_record
template_dir = _template_dir
template_html_path = _template_html_path
template_meta_path = _template_meta_path
read_template_meta = _read_template_meta
load_card = _load_card
load_cards = _load_cards
write_card = _write_card
persist_card = _persist_card
delete_card = _delete_card
delete_cards_for_chat = _delete_cards_for_chat
list_templates = _list_templates
sync_templates_context_file = _sync_templates_context_file
json_script_text = _json_script_text
__all__ = [
"CARD_INSTANCES_DIR",
"CARD_TEMPLATES_DIR",
"NANOBOT_WORKSPACE",
"TEMPLATES_CONTEXT_PATH",
"coerce_card_record",
"delete_card",
"delete_cards_for_chat",
"ensure_card_store_dirs",
"json_script_text",
"list_templates",
"load_card",
"load_cards",
"normalize_card_id",
"normalize_template_key",
"parse_iso_datetime",
"persist_card",
"read_template_meta",
"sync_templates_context_file",
"template_dir",
"template_html_path",
"template_meta_path",
"write_card",
]

View file

@ -0,0 +1,19 @@
{
"id": "live-calendar-timeline-weather",
"kind": "text",
"title": "Today Calendar Weather",
"content": "",
"question": "",
"choices": [],
"response_value": "",
"slot": "live-calendar-timeline-weather",
"lane": "context",
"priority": 89,
"state": "active",
"template_key": "calendar-timeline-weather-live",
"context_summary": "",
"chat_id": "web",
"snooze_until": "",
"created_at": "2026-04-02T00:00:00+00:00",
"updated_at": "2026-04-02T00:00:00+00:00"
}

View file

@ -0,0 +1,16 @@
{
"title": "Today Calendar Weather",
"subtitle": "Family Calendar",
"tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"refresh_ms": 900000,
"min_start_hour": 6,
"max_end_hour": 22,
"min_window_hours": 6,
"slot_height": 24,
"empty_text": "No events for today.",
"weather_tool_name": "exec",
"weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48"
}

View file

@ -0,0 +1,17 @@
{
"id": "live-outdoor-aqi",
"kind": "text",
"title": "Outdoor AQI",
"question": "",
"choices": [],
"response_value": "",
"slot": "live-outdoor-aqi",
"lane": "context",
"priority": 24,
"state": "active",
"template_key": "sensor-live",
"context_summary": "",
"chat_id": "web",
"created_at": "2026-04-05T20:40:00-04:00",
"updated_at": "2026-04-05T20:40:00-04:00"
}

View file

@ -0,0 +1,17 @@
{
"title": "Outdoor AQI",
"subtitle": "Outdoor air quality",
"tool_name": "mcp_home_assistant_GetLiveContext",
"match_name": "Worcester Summer St Air quality index",
"device_class": "aqi",
"unit": "AQI",
"refresh_ms": 300000,
"value_decimals": 0,
"alert_only": true,
"alert_score_elevated": 90,
"alert_score_high": 99,
"thresholds": {
"good_max": 100,
"elevated_max": 150
}
}

View file

@ -4,9 +4,14 @@
"tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
"provider_prefix": "Worcester Summer St",
"temperature_name": "Worcester Summer St Temperature",
"humidity_name": "Worcester Summer St Humidity",
"uv_name": "OpenWeatherMap UV index",
"condition_label": "Weather",
"refresh_ms": 86400000
"morning_start_hour": 6,
"morning_end_hour": 11,
"morning_score": 84,
"default_score": 38,
"refresh_ms": 300000
}

View file

@ -0,0 +1,263 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-calendar-subtitle]');
const statusEl = root.querySelector('[data-calendar-status]');
const rangeEl = root.querySelector('[data-calendar-range]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const listEl = root.querySelector('[data-calendar-list]');
const updatedEl = root.querySelector('[data-calendar-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const rangeDaysRaw = Number(state.range_days);
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
if (!(statusEl instanceof HTMLElement)) return;
statusEl.textContent = label;
statusEl.style.color = color;
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const eventSortKey = (event) => {
const raw = normalizeDateValue(event && event.start);
const time = new Date(raw).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
};
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!host.listTools) {
return { name: fallbackName, availableCalendars: calendarNames };
}
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const summary = document.createElement('div');
summary.style.fontSize = '0.98rem';
summary.style.lineHeight = '1.3';
summary.style.fontWeight = '700';
summary.style.color = 'var(--theme-card-neutral-text)';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
const timing = document.createElement('div');
timing.style.marginTop = '4px';
timing.style.fontSize = '0.9rem';
timing.style.lineHeight = '1.35';
timing.style.color = 'var(--theme-card-neutral-subtle)';
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
item.appendChild(timing);
listEl.appendChild(item);
}
};
const refresh = async () => {
setStatus('Refreshing', 'var(--theme-status-muted)');
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
end.setHours(23, 59, 59, 999);
rangeEl.textContent = formatRange(start, end);
try {
const toolConfig = await resolveToolConfig();
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const resolvedSubtitle = subtitle || selectedCalendars.join(', ');
subtitleEl.textContent = resolvedSubtitle;
const allEvents = [];
const rangeMode = rangeDays > 1 ? 'week' : 'today';
const endExclusiveTime = end.getTime() + 1;
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: rangeMode,
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
renderEvents(allEvents);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
setStatus(statusLabel, 'var(--theme-status-live)');
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
return {
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
day_label: dayLabel === '--' ? null : dayLabel,
time_label: timeLabel,
all_day: isAllDay(event.start, event.end),
};
});
updateLiveContent({
kind: 'calendar_agenda',
subtitle: resolvedSubtitle || null,
tool_name: toolConfig.name,
calendar_names: selectedCalendars,
range_label: rangeEl.textContent || null,
status: statusLabel,
updated_at: updatedText,
event_count: snapshotEvents.length,
events: snapshotEvents,
});
} catch (error) {
const errorText = String(error);
renderEvents([]);
updatedEl.textContent = errorText;
setStatus('Unavailable', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'calendar_agenda',
subtitle: subtitleEl.textContent || null,
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: calendarNames,
range_label: rangeEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
event_count: 0,
events: [],
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,257 +1,10 @@
<div data-calendar-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-calendar-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-calendar-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<div data-calendar-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
</div>
<div data-calendar-range style="font-size:0.88rem; line-height:1.35; color:#6b7280; margin-bottom:6px;">--</div>
<div data-calendar-empty style="display:none; padding:10px 0 2px; color:#475569; font-size:0.96rem; line-height:1.4;">No events scheduled.</div>
<div data-calendar-range style="font-size:0.88rem; line-height:1.35; color:var(--theme-card-neutral-muted); margin-bottom:6px;">--</div>
<div data-calendar-empty style="display:none; padding:10px 0 2px; color:var(--theme-card-neutral-subtle); font-size:0.96rem; line-height:1.4;">No events scheduled.</div>
<ul data-calendar-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-calendar-updated>--</span></div>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">Updated <span data-calendar-updated>--</span></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-calendar-subtitle]');
const statusEl = root.querySelector('[data-calendar-status]');
const rangeEl = root.querySelector('[data-calendar-range]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const listEl = root.querySelector('[data-calendar-list]');
const updatedEl = root.querySelector('[data-calendar-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const rangeDaysRaw = Number(state.range_days);
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
if (!(statusEl instanceof HTMLElement)) return;
statusEl.textContent = label;
statusEl.style.color = color;
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const eventSortKey = (event) => {
const raw = normalizeDateValue(event && event.start);
const time = new Date(raw).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
};
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!window.__nanobotListTools) {
return { name: fallbackName, availableCalendars: calendarNames };
}
try {
const tools = await window.__nanobotListTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
item.style.borderTop = index === 0 ? '1px solid #e5e7eb' : '1px solid #e5e7eb';
const summary = document.createElement('div');
summary.style.fontSize = '0.98rem';
summary.style.lineHeight = '1.3';
summary.style.fontWeight = '700';
summary.style.color = '#111827';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
const timing = document.createElement('div');
timing.style.marginTop = '4px';
timing.style.fontSize = '0.9rem';
timing.style.lineHeight = '1.35';
timing.style.color = '#4b5563';
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
item.appendChild(timing);
listEl.appendChild(item);
}
};
const refresh = async () => {
setStatus('Refreshing', '#6b7280');
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
end.setHours(23, 59, 59, 999);
rangeEl.textContent = formatRange(start, end);
try {
const toolConfig = await resolveToolConfig();
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const resolvedSubtitle = subtitle || selectedCalendars.join(', ');
subtitleEl.textContent = resolvedSubtitle;
const allEvents = [];
const rangeMode = rangeDays > 1 ? 'week' : 'today';
const endExclusiveTime = end.getTime() + 1;
for (const calendarName of selectedCalendars) {
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
calendar: calendarName,
range: rangeMode,
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
renderEvents(allEvents);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
setStatus(statusLabel, '#047857');
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
return {
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
day_label: dayLabel === '--' ? null : dayLabel,
time_label: timeLabel,
all_day: isAllDay(event.start, event.end),
};
});
updateLiveContent({
kind: 'calendar_agenda',
subtitle: resolvedSubtitle || null,
tool_name: toolConfig.name,
calendar_names: selectedCalendars,
range_label: rangeEl.textContent || null,
status: statusLabel,
updated_at: updatedText,
event_count: snapshotEvents.length,
events: snapshotEvents,
});
} catch (error) {
const errorText = String(error);
renderEvents([]);
updatedEl.textContent = errorText;
setStatus('Unavailable', '#b91c1c');
updateLiveContent({
kind: 'calendar_agenda',
subtitle: subtitleEl.textContent || null,
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: calendarNames,
range_label: rangeEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
event_count: 0,
events: [],
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -0,0 +1,584 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const headlineEl = root.querySelector('[data-calendar-headline]');
const detailEl = root.querySelector('[data-calendar-detail]');
const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]');
const allDayEl = root.querySelector('[data-calendar-all-day]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]');
const timelineEl = root.querySelector('[data-calendar-timeline]');
if (!(headlineEl instanceof HTMLElement) ||
!(detailEl instanceof HTMLElement) ||
!(allDayWrapEl instanceof HTMLElement) ||
!(allDayEl instanceof HTMLElement) ||
!(emptyEl instanceof HTMLElement) ||
!(timelineShellEl instanceof HTMLElement) ||
!(timelineEl instanceof HTMLElement)) {
return;
}
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const minStartHourRaw = Number(state.min_start_hour);
const maxEndHourRaw = Number(state.max_end_hour);
const minWindowHoursRaw = Number(state.min_window_hours);
const slotHeightRaw = Number(state.slot_height);
const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6;
const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22;
const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60;
const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
? state.empty_text.trim()
: 'No events for today.';
const LABEL_WIDTH = 42;
const TRACK_TOP_PAD = 6;
const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events';
let latestEvents = [];
let latestSelectedCalendars = [];
let latestUpdatedAt = '';
let clockIntervalId = null;
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const isAllDay = (start, end) =>
/^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) ||
/^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const parseDate = (value, allDay, endOfDay = false) => {
const raw = normalizeDateValue(value);
if (!raw) return null;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}`
: raw;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
};
const eventBounds = (event) => {
const allDay = isAllDay(event?.start, event?.end);
const startDate = parseDate(event?.start, allDay, false);
const endDate = parseDate(event?.end, allDay, allDay);
if (!startDate) return null;
const start = startDate.getTime();
let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000;
if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000;
return { start, end, allDay };
};
const formatClock = (date) => {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
const formatDistance = (ms) => {
const totalMinutes = Math.max(0, Math.round(ms / 60000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours <= 0) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
};
const formatTimeRange = (event) => {
const bounds = eventBounds(event);
if (!bounds) return '--';
if (bounds.allDay) return 'All day';
return `${formatClock(new Date(bounds.start))}${formatClock(new Date(bounds.end))}`;
};
const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0');
const minutesIntoDay = (time) => {
const date = new Date(time);
return date.getHours() * 60 + date.getMinutes();
};
const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30;
const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30;
const computeVisibleWindow = (events) => {
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const timedEvents = events.filter((event) => !event._allDay);
if (timedEvents.length === 0) {
let start = roundDownToHalfHour(nowMinutes - 120);
let end = start + minWindowMinutes;
const minBound = minStartHour * 60;
const maxBound = maxEndHour * 60;
if (start < minBound) {
start = minBound;
end = start + minWindowMinutes;
}
if (end > maxBound) {
end = maxBound;
start = Math.max(minBound, end - minWindowMinutes);
}
return { start, end };
}
const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start)));
const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end)));
let start = roundDownToHalfHour(earliest - 60);
let end = roundUpToHalfHour(latest + 90);
if (end - start < minWindowMinutes) {
const center = roundDownToHalfHour((earliest + latest) / 2);
start = center - Math.floor(minWindowMinutes / 2);
end = start + minWindowMinutes;
}
const minBound = minStartHour * 60;
const maxBound = maxEndHour * 60;
if (start < minBound) {
const shift = minBound - start;
start += shift;
end += shift;
}
if (end > maxBound) {
const shift = end - maxBound;
start -= shift;
end -= shift;
}
start = Math.max(minBound, start);
end = Math.min(maxBound, end);
if (end - start < 120) {
end = Math.min(maxBound, start + 120);
}
return { start, end };
};
const assignColumns = (events) => {
const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start);
let active = [];
let cluster = [];
let clusterEnd = -Infinity;
let clusterMax = 1;
const finalizeCluster = () => {
for (const item of cluster) item._columns = clusterMax;
};
for (const event of timed) {
if (cluster.length > 0 && event._start >= clusterEnd) {
finalizeCluster();
active = [];
cluster = [];
clusterEnd = -Infinity;
clusterMax = 1;
}
active = active.filter((item) => item.end > event._start);
const used = new Set(active.map((item) => item.column));
let column = 0;
while (used.has(column)) column += 1;
event._column = column;
active.push({ end: event._end, column });
cluster.push(event);
clusterEnd = Math.max(clusterEnd, event._end);
clusterMax = Math.max(clusterMax, active.length, column + 1);
}
if (cluster.length > 0) finalizeCluster();
return timed;
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) {
return toolResult.parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result;
} catch {}
}
return [];
};
const resolveToolConfig = async () => {
if (!host.listTools) {
return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
}
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || TOOL_FALLBACK,
availableCalendars: enumValues,
};
} catch {
return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
}
};
const computeScore = (events, nowTime) => {
const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime);
if (current) return 99;
const next = events.find((event) => !event._allDay && event._start > nowTime);
if (!next) {
if (events.some((event) => event._allDay)) return 74;
return 18;
}
const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000));
let score = 70;
if (minutesAway <= 15) score = 98;
else if (minutesAway <= 30) score = 95;
else if (minutesAway <= 60) score = 92;
else if (minutesAway <= 180) score = 88;
else if (minutesAway <= 360) score = 82;
else score = 76;
score += Math.min(events.length, 3);
return Math.min(100, score);
};
const createAllDayChip = (event) => {
const chip = document.createElement('div');
chip.style.padding = '3px 6px';
chip.style.border = '1px solid rgba(161, 118, 84, 0.28)';
chip.style.background = 'rgba(255, 249, 241, 0.94)';
chip.style.color = '#5e412d';
chip.style.fontSize = '0.62rem';
chip.style.lineHeight = '1.2';
chip.style.fontWeight = '700';
chip.style.minWidth = '0';
chip.style.maxWidth = '100%';
chip.style.overflow = 'hidden';
chip.style.textOverflow = 'ellipsis';
chip.style.whiteSpace = 'nowrap';
chip.textContent = String(event.summary || '(No title)');
return chip;
};
const renderState = () => {
const now = new Date();
const nowTime = now.getTime();
const todayLabel = formatShortDate(now);
if (!Array.isArray(latestEvents) || latestEvents.length === 0) {
headlineEl.textContent = todayLabel;
detailEl.textContent = emptyText;
allDayWrapEl.style.display = 'none';
timelineShellEl.style.display = 'none';
emptyEl.style.display = 'block';
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: latestUpdatedAt || null,
now_label: formatClock(now),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
event_count: 0,
all_day_count: 0,
score: 18,
events: [],
});
return;
}
const allDayEvents = latestEvents.filter((event) => event._allDay);
const timedEvents = latestEvents.filter((event) => !event._allDay);
const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null;
const nextEvent = timedEvents.find((event) => event._start > nowTime) || null;
headlineEl.textContent = todayLabel;
if (currentEvent) {
detailEl.textContent = '';
} else if (nextEvent) {
detailEl.textContent = '';
} else if (allDayEvents.length > 0) {
detailEl.textContent = 'All-day events on your calendar.';
} else {
detailEl.textContent = 'Your calendar is clear for the rest of the day.';
}
const windowRange = computeVisibleWindow(latestEvents);
allDayEl.innerHTML = '';
allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none';
for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event));
emptyEl.style.display = 'none';
timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none';
timelineEl.innerHTML = '';
if (timedEvents.length > 0) {
const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30));
const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight;
timelineEl.style.height = `${timelineHeight}px`;
const gridLayer = document.createElement('div');
gridLayer.style.position = 'absolute';
gridLayer.style.inset = '0';
timelineEl.appendChild(gridLayer);
for (let index = 0; index <= slotCount; index += 1) {
const minutes = windowRange.start + index * 30;
const top = TRACK_TOP_PAD + index * slotHeight;
const line = document.createElement('div');
line.style.position = 'absolute';
line.style.left = `${LABEL_WIDTH}px`;
line.style.right = '0';
line.style.top = `${top}px`;
line.style.borderTop = minutes % 60 === 0
? '1px solid rgba(143, 101, 69, 0.24)'
: '1px dashed rgba(181, 145, 116, 0.18)';
gridLayer.appendChild(line);
if (minutes % 60 === 0 && minutes < windowRange.end) {
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.left = '0';
label.style.top = `${Math.max(0, top - 7)}px`;
label.style.width = `${LABEL_WIDTH - 8}px`;
label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace";
label.style.fontSize = '0.54rem';
label.style.lineHeight = '1';
label.style.color = '#8a6248';
label.style.textAlign = 'right';
label.style.textTransform = 'uppercase';
label.style.letterSpacing = '0.05em';
label.textContent = hourLabel(minutes);
gridLayer.appendChild(label);
}
}
const eventsLayer = document.createElement('div');
eventsLayer.style.position = 'absolute';
eventsLayer.style.left = `${LABEL_WIDTH + 6}px`;
eventsLayer.style.right = '0';
eventsLayer.style.top = `${TRACK_TOP_PAD}px`;
eventsLayer.style.bottom = '0';
timelineEl.appendChild(eventsLayer);
const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event })));
for (const event of layoutEvents) {
const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start;
const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start;
const top = Math.max(0, (offsetStart / 30) * slotHeight);
const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3);
const block = document.createElement('div');
block.style.position = 'absolute';
block.style.top = `${top}px`;
block.style.height = `${height}px`;
block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`;
block.style.width = `calc(${100 / event._columns}% - 4px)`;
block.style.padding = '5px 6px';
block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
? '1px solid rgba(169, 39, 29, 0.38)'
: '1px solid rgba(162, 105, 62, 0.26)';
block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)'
: 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)';
block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)';
block.style.overflow = 'hidden';
const title = document.createElement('div');
title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif";
title.style.fontSize = '0.74rem';
title.style.lineHeight = '0.98';
title.style.letterSpacing = '-0.01em';
title.style.color = '#22140c';
title.style.fontWeight = '700';
title.style.whiteSpace = 'nowrap';
title.style.textOverflow = 'ellipsis';
title.style.overflow = 'hidden';
title.textContent = String(event.summary || '(No title)');
block.appendChild(title);
eventsLayer.appendChild(block);
}
if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() &&
nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) {
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight;
const line = document.createElement('div');
line.style.position = 'absolute';
line.style.left = `${LABEL_WIDTH + 6}px`;
line.style.right = '0';
line.style.top = `${nowTop}px`;
line.style.borderTop = '1.5px solid #cf2f21';
line.style.transition = 'top 28s linear';
timelineEl.appendChild(line);
const dot = document.createElement('div');
dot.style.position = 'absolute';
dot.style.left = `${LABEL_WIDTH + 1}px`;
dot.style.top = `${nowTop - 4}px`;
dot.style.width = '8px';
dot.style.height = '8px';
dot.style.borderRadius = '999px';
dot.style.background = '#cf2f21';
dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)';
dot.style.transition = 'top 28s linear';
timelineEl.appendChild(dot);
}
}
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: latestUpdatedAt || null,
now_label: formatClock(now),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
current_event: currentEvent ? {
summary: String(currentEvent.summary || '(No title)'),
start: normalizeDateValue(currentEvent.start) || null,
end: normalizeDateValue(currentEvent.end) || null,
} : null,
next_event: nextEvent ? {
summary: String(nextEvent.summary || '(No title)'),
start: normalizeDateValue(nextEvent.start) || null,
end: normalizeDateValue(nextEvent.end) || null,
starts_in: formatDistance(nextEvent._start - nowTime),
} : null,
event_count: latestEvents.length,
all_day_count: allDayEvents.length,
score: computeScore(latestEvents, nowTime),
events: latestEvents.map((event) => ({
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
all_day: Boolean(event._allDay),
})),
});
};
const refresh = async () => {
headlineEl.textContent = 'Loading today…';
detailEl.textContent = 'Checking your calendar.';
try {
const toolConfig = await resolveToolConfig();
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const allEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const event of events) {
const bounds = eventBounds(event);
if (!bounds) continue;
allEvents.push({
...event,
_calendarName: calendarName,
_start: bounds.start,
_end: bounds.end,
_allDay: bounds.allDay,
});
}
}
allEvents.sort((left, right) => left._start - right._start);
latestEvents = allEvents;
latestSelectedCalendars = selectedCalendars;
latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
renderState();
} catch (error) {
const errorText = String(error);
latestEvents = [];
latestSelectedCalendars = calendarNames;
latestUpdatedAt = errorText;
headlineEl.textContent = formatShortDate(new Date());
detailEl.textContent = errorText;
allDayWrapEl.style.display = 'none';
timelineShellEl.style.display = 'none';
emptyEl.style.display = 'block';
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: errorText,
now_label: formatClock(new Date()),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
event_count: 0,
all_day_count: 0,
score: 0,
events: [],
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
if (clockIntervalId) window.clearInterval(clockIntervalId);
clockIntervalId = __setInterval(() => {
renderState();
}, 30000);
void refresh();
__setInterval(() => {
void refresh();
}, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -0,0 +1,20 @@
{
"key": "calendar-timeline-live",
"title": "Today Calendar Timeline",
"notes": "Today-only Home Assistant calendar timeline with half-hour grid, current time marker, and next-event distance. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, and empty_text.",
"example_state": {
"subtitle": "Family Calendar",
"tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"refresh_ms": 900000,
"min_start_hour": 6,
"max_end_hour": 22,
"min_window_hours": 6,
"slot_height": 24,
"empty_text": "No events for today."
},
"created_at": "2026-04-02T00:00:00+00:00",
"updated_at": "2026-04-02T00:00:00+00:00"
}

View file

@ -0,0 +1,38 @@
<style>
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'M-1m Code';
src: url('/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
<div data-calendar-timeline-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-warm-bg); color:var(--theme-card-warm-text); padding:12px; border:1px solid var(--theme-card-warm-border);">
<div style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:0.86rem; line-height:1.02; letter-spacing:-0.01em; color:var(--theme-card-warm-text); font-weight:700;">Today Calendar</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-top:8px;">
<div style="min-width:0; flex:1 1 auto;">
<div data-calendar-headline style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:1.08rem; line-height:0.98; letter-spacing:-0.03em; color:var(--theme-card-warm-text); font-weight:700;">Loading today…</div>
<div data-calendar-detail style="margin-top:4px; font-size:0.72rem; line-height:1.18; color:var(--theme-card-warm-muted);">Checking your calendar.</div>
</div>
</div>
<div data-calendar-all-day-wrap style="display:none; margin-top:8px;">
<div style="font-family:'M-1m Code', ui-monospace, Menlo, Consolas, monospace; font-size:0.58rem; line-height:1.1; letter-spacing:0.09em; text-transform:uppercase; color:var(--theme-card-warm-muted); margin-bottom:5px;">All day</div>
<div data-calendar-all-day style="display:flex; flex-wrap:wrap; gap:4px;"></div>
</div>
<div data-calendar-empty style="display:none; margin-top:10px; padding:10px 0 2px; color:var(--theme-card-warm-muted); font-size:0.92rem; line-height:1.35;">No events for today.</div>
<div data-calendar-timeline-shell style="display:none; margin-top:10px;">
<div data-calendar-timeline style="position:relative; min-height:160px;"></div>
</div>
</div>

View file

@ -0,0 +1,778 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const headlineEl = root.querySelector('[data-calendar-headline]');
const detailEl = root.querySelector('[data-calendar-detail]');
const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]');
const allDayEl = root.querySelector('[data-calendar-all-day]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]');
const weatherScaleEl = root.querySelector('[data-calendar-weather-scale]');
const weatherLowEl = root.querySelector('[data-calendar-weather-low]');
const weatherHighEl = root.querySelector('[data-calendar-weather-high]');
const timelineEl = root.querySelector('[data-calendar-timeline]');
if (!(headlineEl instanceof HTMLElement) ||
!(detailEl instanceof HTMLElement) ||
!(allDayWrapEl instanceof HTMLElement) ||
!(allDayEl instanceof HTMLElement) ||
!(emptyEl instanceof HTMLElement) ||
!(timelineShellEl instanceof HTMLElement) ||
!(weatherScaleEl instanceof HTMLElement) ||
!(weatherLowEl instanceof HTMLElement) ||
!(weatherHighEl instanceof HTMLElement) ||
!(timelineEl instanceof HTMLElement)) {
return;
}
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : 'exec';
const weatherCommand = typeof state.weather_command === 'string' ? state.weather_command.trim() : '';
const minStartHourRaw = Number(state.min_start_hour);
const maxEndHourRaw = Number(state.max_end_hour);
const minWindowHoursRaw = Number(state.min_window_hours);
const slotHeightRaw = Number(state.slot_height);
const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6;
const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22;
const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60;
const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
? state.empty_text.trim()
: 'No events for today.';
const LABEL_WIDTH = 42;
const TRACK_TOP_PAD = 6;
const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events';
let latestEvents = [];
let latestSelectedCalendars = [];
let latestUpdatedAt = '';
let latestWeatherPoints = [];
let latestWeatherRange = null;
let clockIntervalId = null;
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const isAllDay = (start, end) =>
/^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) ||
/^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const parseDate = (value, allDay, endOfDay = false) => {
const raw = normalizeDateValue(value);
if (!raw) return null;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}`
: raw;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
};
const eventBounds = (event) => {
const allDay = isAllDay(event?.start, event?.end);
const startDate = parseDate(event?.start, allDay, false);
const endDate = parseDate(event?.end, allDay, allDay);
if (!startDate) return null;
const start = startDate.getTime();
let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000;
if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000;
return { start, end, allDay };
};
const formatClock = (date) => {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
const formatDistance = (ms) => {
const totalMinutes = Math.max(0, Math.round(ms / 60000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours <= 0) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
};
const formatTimeRange = (event) => {
const bounds = eventBounds(event);
if (!bounds) return '--';
if (bounds.allDay) return 'All day';
return `${formatClock(new Date(bounds.start))}${formatClock(new Date(bounds.end))}`;
};
const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0');
const minutesIntoDay = (time) => {
const date = new Date(time);
return date.getHours() * 60 + date.getMinutes();
};
const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30;
const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30;
const computeWeatherWindow = () => {
if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length < 2) return null;
const sorted = latestWeatherPoints
.map((point) => minutesIntoDay(point.time))
.filter((minutes) => Number.isFinite(minutes))
.sort((left, right) => left - right);
if (sorted.length < 2) return null;
const start = roundDownToHalfHour(sorted[0]);
const end = roundUpToHalfHour(sorted[sorted.length - 1]);
if (end <= start) return null;
return { start, end };
};
const computeVisibleWindow = (events) => {
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const timedEvents = events.filter((event) => !event._allDay);
const weatherWindow = computeWeatherWindow();
if (timedEvents.length === 0) {
let start = roundDownToHalfHour(nowMinutes - 120);
let end = start + minWindowMinutes;
const minBound = minStartHour * 60;
const maxBound = maxEndHour * 60;
if (start < minBound) {
start = minBound;
end = start + minWindowMinutes;
}
if (end > maxBound) {
end = maxBound;
start = Math.max(minBound, end - minWindowMinutes);
}
if (weatherWindow) {
start = Math.max(start, weatherWindow.start);
end = Math.min(end, weatherWindow.end);
if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
}
return { start, end };
}
const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start)));
const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end)));
let start = roundDownToHalfHour(earliest - 60);
let end = roundUpToHalfHour(latest + 90);
if (end - start < minWindowMinutes) {
const center = roundDownToHalfHour((earliest + latest) / 2);
start = center - Math.floor(minWindowMinutes / 2);
end = start + minWindowMinutes;
}
const minBound = minStartHour * 60;
const maxBound = maxEndHour * 60;
if (start < minBound) {
const shift = minBound - start;
start += shift;
end += shift;
}
if (end > maxBound) {
const shift = end - maxBound;
start -= shift;
end -= shift;
}
start = Math.max(minBound, start);
end = Math.min(maxBound, end);
if (end - start < 120) {
end = Math.min(maxBound, start + 120);
}
if (weatherWindow) {
start = Math.max(start, weatherWindow.start);
end = Math.min(end, weatherWindow.end);
if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
}
return { start, end };
};
const assignColumns = (events) => {
const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start);
let active = [];
let cluster = [];
let clusterEnd = -Infinity;
let clusterMax = 1;
const finalizeCluster = () => {
for (const item of cluster) item._columns = clusterMax;
};
for (const event of timed) {
if (cluster.length > 0 && event._start >= clusterEnd) {
finalizeCluster();
active = [];
cluster = [];
clusterEnd = -Infinity;
clusterMax = 1;
}
active = active.filter((item) => item.end > event._start);
const used = new Set(active.map((item) => item.column));
let column = 0;
while (used.has(column)) column += 1;
event._column = column;
active.push({ end: event._end, column });
cluster.push(event);
clusterEnd = Math.max(clusterEnd, event._end);
clusterMax = Math.max(clusterMax, active.length, column + 1);
}
if (cluster.length > 0) finalizeCluster();
return timed;
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) {
return toolResult.parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result;
} catch {}
}
return [];
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const text = stripExecFooter(toolResult?.content);
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
};
const forecastTime = (entry) => {
const time = new Date(String(entry?.datetime || '')).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const extractWeatherPoints = (payload) => {
const rows = Array.isArray(payload?.nws?.forecast) ? payload.nws.forecast : [];
const today = new Date();
const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime();
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
return rows
.map((entry) => {
const time = forecastTime(entry);
const temp = Number(entry?.temperature);
if (!Number.isFinite(time) || !Number.isFinite(temp)) return null;
if (time < dayStart || time >= dayEnd) return null;
return {
time,
temp,
};
})
.filter(Boolean);
};
const resolveWeatherForecast = async () => {
if (!weatherCommand) return [];
const toolResult = await host.callTool(configuredWeatherToolName || 'exec', {
command: weatherCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
if (!payload || typeof payload !== 'object') return [];
return extractWeatherPoints(payload);
};
const resolveToolConfig = async () => {
if (!host.listTools) {
return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
}
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || TOOL_FALLBACK,
availableCalendars: enumValues,
};
} catch {
return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
}
};
const computeScore = (events, nowTime) => {
const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime);
if (current) return 99;
const next = events.find((event) => !event._allDay && event._start > nowTime);
if (!next) {
if (events.some((event) => event._allDay)) return 74;
return 18;
}
const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000));
let score = 70;
if (minutesAway <= 15) score = 98;
else if (minutesAway <= 30) score = 95;
else if (minutesAway <= 60) score = 92;
else if (minutesAway <= 180) score = 88;
else if (minutesAway <= 360) score = 82;
else score = 76;
score += Math.min(events.length, 3);
return Math.min(100, score);
};
const createAllDayChip = (event) => {
const chip = document.createElement('div');
chip.style.padding = '3px 6px';
chip.style.border = '1px solid rgba(161, 118, 84, 0.28)';
chip.style.background = 'rgba(255, 249, 241, 0.94)';
chip.style.color = '#5e412d';
chip.style.fontSize = '0.62rem';
chip.style.lineHeight = '1.2';
chip.style.fontWeight = '700';
chip.style.minWidth = '0';
chip.style.maxWidth = '100%';
chip.style.overflow = 'hidden';
chip.style.textOverflow = 'ellipsis';
chip.style.whiteSpace = 'nowrap';
chip.textContent = String(event.summary || '(No title)');
return chip;
};
const computeWeatherRange = (points) => {
if (!Array.isArray(points) || points.length === 0) return null;
let low = Number.POSITIVE_INFINITY;
let high = Number.NEGATIVE_INFINITY;
for (const point of points) {
low = Math.min(low, point.temp);
high = Math.max(high, point.temp);
}
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low === high) high = low + 1;
return { low, high };
};
const renderWeatherGraph = (windowRange, timelineHeight) => {
weatherScaleEl.style.display = 'none';
weatherLowEl.textContent = '--';
weatherHighEl.textContent = '--';
if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length === 0 || !latestWeatherRange) return;
const visiblePoints = latestWeatherPoints.filter((point) => {
const minutes = minutesIntoDay(point.time);
return minutes >= windowRange.start && minutes <= windowRange.end;
});
if (visiblePoints.length < 2) return;
weatherScaleEl.style.display = 'flex';
weatherLowEl.textContent = `${Math.round(latestWeatherRange.low)}°`;
weatherHighEl.textContent = `${Math.round(latestWeatherRange.high)}°`;
const timelineWidth = timelineEl.getBoundingClientRect().width;
const overlayWidth = Math.max(1, timelineWidth - (LABEL_WIDTH + 6));
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
overlay.setAttribute('viewBox', `0 0 ${overlayWidth} ${timelineHeight}`);
overlay.style.position = 'absolute';
overlay.style.left = `${LABEL_WIDTH + 6}px`;
overlay.style.top = '0';
overlay.style.height = `${timelineHeight}px`;
overlay.style.width = `${overlayWidth}px`;
overlay.style.pointerEvents = 'none';
overlay.style.opacity = '0.85';
const toPoint = (point) => {
const minutes = minutesIntoDay(point.time);
const y = TRACK_TOP_PAD + ((minutes - windowRange.start) / 30) * slotHeight;
const x = ((point.temp - latestWeatherRange.low) / (latestWeatherRange.high - latestWeatherRange.low)) * overlayWidth;
return { x, y };
};
const coords = visiblePoints.map(toPoint);
const buildSmoothPath = (points) => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index];
const next = points[index + 1];
const midX = (point.x + next.x) / 2;
const midY = (point.y + next.y) / 2;
d += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`;
}
const penultimate = points[points.length - 2];
const last = points[points.length - 1];
d += ` Q ${penultimate.x.toFixed(2)} ${penultimate.y.toFixed(2)} ${last.x.toFixed(2)} ${last.y.toFixed(2)}`;
return d;
};
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', buildSmoothPath(coords));
line.setAttribute('fill', 'none');
line.setAttribute('stroke', 'rgba(184, 110, 58, 0.34)');
line.setAttribute('stroke-width', '1.8');
line.setAttribute('stroke-linecap', 'round');
line.setAttribute('stroke-linejoin', 'round');
line.setAttribute('vector-effect', 'non-scaling-stroke');
overlay.appendChild(line);
for (const point of coords) {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', point.x.toFixed(2));
dot.setAttribute('cy', point.y.toFixed(2));
dot.setAttribute('r', '1.9');
dot.setAttribute('fill', 'rgba(184, 110, 58, 0.34)');
dot.setAttribute('stroke', 'rgba(226, 188, 156, 0.62)');
dot.setAttribute('stroke-width', '0.55');
overlay.appendChild(dot);
}
timelineEl.appendChild(overlay);
};
const renderState = () => {
const now = new Date();
const nowTime = now.getTime();
const todayLabel = formatShortDate(now);
if (!Array.isArray(latestEvents) || latestEvents.length === 0) {
headlineEl.textContent = todayLabel;
detailEl.textContent = emptyText;
allDayWrapEl.style.display = 'none';
timelineShellEl.style.display = 'none';
emptyEl.style.display = 'block';
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: latestUpdatedAt || null,
now_label: formatClock(now),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
weather_temperature_range: latestWeatherRange ? {
low: latestWeatherRange.low,
high: latestWeatherRange.high,
} : null,
event_count: 0,
all_day_count: 0,
score: 18,
events: [],
});
return;
}
const allDayEvents = latestEvents.filter((event) => event._allDay);
const timedEvents = latestEvents.filter((event) => !event._allDay);
const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null;
const nextEvent = timedEvents.find((event) => event._start > nowTime) || null;
headlineEl.textContent = todayLabel;
if (currentEvent) {
detailEl.textContent = '';
} else if (nextEvent) {
detailEl.textContent = '';
} else if (allDayEvents.length > 0) {
detailEl.textContent = 'All-day events on your calendar.';
} else {
detailEl.textContent = 'Your calendar is clear for the rest of the day.';
}
const windowRange = computeVisibleWindow(latestEvents);
allDayEl.innerHTML = '';
allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none';
for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event));
emptyEl.style.display = 'none';
timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none';
timelineEl.innerHTML = '';
if (timedEvents.length > 0) {
const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30));
const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight;
timelineEl.style.height = `${timelineHeight}px`;
const gridLayer = document.createElement('div');
gridLayer.style.position = 'absolute';
gridLayer.style.inset = '0';
timelineEl.appendChild(gridLayer);
for (let index = 0; index <= slotCount; index += 1) {
const minutes = windowRange.start + index * 30;
const top = TRACK_TOP_PAD + index * slotHeight;
const line = document.createElement('div');
line.style.position = 'absolute';
line.style.left = `${LABEL_WIDTH}px`;
line.style.right = '0';
line.style.top = `${top}px`;
line.style.borderTop = minutes % 60 === 0
? '1px solid rgba(143, 101, 69, 0.24)'
: '1px dashed rgba(181, 145, 116, 0.18)';
gridLayer.appendChild(line);
if (minutes % 60 === 0 && minutes < windowRange.end) {
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.left = '0';
label.style.top = `${Math.max(0, top - 7)}px`;
label.style.width = `${LABEL_WIDTH - 8}px`;
label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace";
label.style.fontSize = '0.54rem';
label.style.lineHeight = '1';
label.style.color = '#8a6248';
label.style.textAlign = 'right';
label.style.textTransform = 'uppercase';
label.style.letterSpacing = '0.05em';
label.textContent = hourLabel(minutes);
gridLayer.appendChild(label);
}
}
renderWeatherGraph(windowRange, timelineHeight);
const eventsLayer = document.createElement('div');
eventsLayer.style.position = 'absolute';
eventsLayer.style.left = `${LABEL_WIDTH + 6}px`;
eventsLayer.style.right = '0';
eventsLayer.style.top = `${TRACK_TOP_PAD}px`;
eventsLayer.style.bottom = '0';
timelineEl.appendChild(eventsLayer);
const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event })));
for (const event of layoutEvents) {
const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start;
const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start;
const top = Math.max(0, (offsetStart / 30) * slotHeight);
const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3);
const block = document.createElement('div');
block.style.position = 'absolute';
block.style.top = `${top}px`;
block.style.height = `${height}px`;
block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`;
block.style.width = `calc(${100 / event._columns}% - 4px)`;
block.style.padding = '5px 6px';
block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
? '1px solid rgba(169, 39, 29, 0.38)'
: '1px solid rgba(162, 105, 62, 0.26)';
block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)'
: 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)';
block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)';
block.style.overflow = 'hidden';
const title = document.createElement('div');
title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif";
title.style.fontSize = '0.74rem';
title.style.lineHeight = '0.98';
title.style.letterSpacing = '-0.01em';
title.style.color = '#22140c';
title.style.fontWeight = '700';
title.style.whiteSpace = 'nowrap';
title.style.textOverflow = 'ellipsis';
title.style.overflow = 'hidden';
title.textContent = String(event.summary || '(No title)');
block.appendChild(title);
eventsLayer.appendChild(block);
}
if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() &&
nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) {
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight;
const line = document.createElement('div');
line.style.position = 'absolute';
line.style.left = `${LABEL_WIDTH + 6}px`;
line.style.right = '0';
line.style.top = `${nowTop}px`;
line.style.borderTop = '1.5px solid #cf2f21';
line.style.transition = 'top 28s linear';
timelineEl.appendChild(line);
const dot = document.createElement('div');
dot.style.position = 'absolute';
dot.style.left = `${LABEL_WIDTH + 1}px`;
dot.style.top = `${nowTop - 4}px`;
dot.style.width = '8px';
dot.style.height = '8px';
dot.style.borderRadius = '999px';
dot.style.background = '#cf2f21';
dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)';
dot.style.transition = 'top 28s linear';
timelineEl.appendChild(dot);
}
}
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: latestUpdatedAt || null,
now_label: formatClock(now),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
weather_temperature_range: latestWeatherRange ? {
low: latestWeatherRange.low,
high: latestWeatherRange.high,
} : null,
current_event: currentEvent ? {
summary: String(currentEvent.summary || '(No title)'),
start: normalizeDateValue(currentEvent.start) || null,
end: normalizeDateValue(currentEvent.end) || null,
} : null,
next_event: nextEvent ? {
summary: String(nextEvent.summary || '(No title)'),
start: normalizeDateValue(nextEvent.start) || null,
end: normalizeDateValue(nextEvent.end) || null,
starts_in: formatDistance(nextEvent._start - nowTime),
} : null,
event_count: latestEvents.length,
all_day_count: allDayEvents.length,
score: computeScore(latestEvents, nowTime),
events: latestEvents.map((event) => ({
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
all_day: Boolean(event._allDay),
})),
});
};
const refresh = async () => {
headlineEl.textContent = 'Loading today…';
detailEl.textContent = 'Checking your calendar.';
try {
const toolConfig = await resolveToolConfig();
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const [weatherPoints, allEvents] = await Promise.all([
resolveWeatherForecast().catch(() => []),
(async () => {
const allEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const event of events) {
const bounds = eventBounds(event);
if (!bounds) continue;
allEvents.push({
...event,
_calendarName: calendarName,
_start: bounds.start,
_end: bounds.end,
_allDay: bounds.allDay,
});
}
}
return allEvents;
})(),
]);
allEvents.sort((left, right) => left._start - right._start);
latestWeatherPoints = weatherPoints;
latestWeatherRange = computeWeatherRange(weatherPoints);
latestEvents = allEvents;
latestSelectedCalendars = selectedCalendars;
latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
renderState();
} catch (error) {
const errorText = String(error);
latestWeatherPoints = [];
latestWeatherRange = null;
latestEvents = [];
latestSelectedCalendars = calendarNames;
latestUpdatedAt = errorText;
headlineEl.textContent = formatShortDate(new Date());
detailEl.textContent = errorText;
allDayWrapEl.style.display = 'none';
timelineShellEl.style.display = 'none';
emptyEl.style.display = 'block';
updateLiveContent({
kind: 'calendar_timeline',
subtitle: subtitle || null,
tool_name: TOOL_FALLBACK,
calendar_names: latestSelectedCalendars,
updated_at: errorText,
now_label: formatClock(new Date()),
headline: headlineEl.textContent || null,
detail: detailEl.textContent || null,
weather_temperature_range: null,
event_count: 0,
all_day_count: 0,
score: 0,
events: [],
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
if (clockIntervalId) window.clearInterval(clockIntervalId);
clockIntervalId = __setInterval(() => {
renderState();
}, 30000);
void refresh();
__setInterval(() => {
void refresh();
}, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -0,0 +1,22 @@
{
"key": "calendar-timeline-weather-live",
"title": "Today Calendar Weather Timeline",
"notes": "Experimental copy of the today-only Home Assistant calendar timeline with a subtle hourly temperature graph behind the timeline. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, empty_text, weather_tool_name (defaults to exec), and weather_command.",
"example_state": {
"subtitle": "Family Calendar",
"tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"refresh_ms": 900000,
"min_start_hour": 6,
"max_end_hour": 22,
"min_window_hours": 6,
"slot_height": 24,
"empty_text": "No events for today.",
"weather_tool_name": "exec",
"weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48"
},
"created_at": "2026-04-02T00:00:00+00:00",
"updated_at": "2026-04-02T00:00:00+00:00"
}

View file

@ -0,0 +1,41 @@
<style>
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'M-1m Code';
src: url('/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
<div data-calendar-timeline-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-warm-bg); color:var(--theme-card-warm-text); padding:12px; border:1px solid var(--theme-card-warm-border);">
<div style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:0.86rem; line-height:1.02; letter-spacing:-0.01em; color:var(--theme-card-warm-text); font-weight:700;">Today Calendar + Weather</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-top:8px;">
<div style="min-width:0; flex:1 1 auto;">
<div data-calendar-headline style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:1.08rem; line-height:0.98; letter-spacing:-0.03em; color:var(--theme-card-warm-text); font-weight:700;">Loading today…</div>
<div data-calendar-detail style="margin-top:4px; font-size:0.72rem; line-height:1.18; color:var(--theme-card-warm-muted);">Checking your calendar.</div>
</div>
</div>
<div data-calendar-all-day-wrap style="display:none; margin-top:8px;">
<div data-calendar-all-day style="display:flex; flex-wrap:wrap; gap:4px;"></div>
</div>
<div data-calendar-empty style="display:none; margin-top:10px; padding:10px 0 2px; color:var(--theme-card-warm-muted); font-size:0.92rem; line-height:1.35;">No events for today.</div>
<div data-calendar-timeline-shell style="display:none; margin-top:10px;">
<div data-calendar-weather-scale style="display:none; align-items:center; justify-content:space-between; margin:0 0 6px 48px; font-family:'M-1m Code', ui-monospace, Menlo, Consolas, monospace; font-size:0.54rem; line-height:1; color:var(--theme-card-warm-muted);">
<span data-calendar-weather-low>--</span>
<span data-calendar-weather-high>--</span>
</div>
<div data-calendar-timeline style="position:relative; min-height:160px;"></div>
</div>
</div>

View file

@ -0,0 +1,719 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-git-subtitle]');
const branchEl = root.querySelector('[data-git-branch]');
const statusEl = root.querySelector('[data-git-status]');
const changedEl = root.querySelector('[data-git-changed]');
const stagingEl = root.querySelector('[data-git-staging]');
const untrackedEl = root.querySelector('[data-git-untracked]');
const upstreamEl = root.querySelector('[data-git-upstream]');
const plusEl = root.querySelector('[data-git-plus]');
const minusEl = root.querySelector('[data-git-minus]');
const updatedEl = root.querySelector('[data-git-updated]');
const filesEl = root.querySelector('[data-git-files]');
if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
const setStatus = (label, fg, bg) => {
statusEl.textContent = label;
statusEl.style.color = fg;
statusEl.style.background = bg;
statusEl.style.padding = '3px 7px';
statusEl.style.borderRadius = '999px';
};
const statusTone = (value) => {
if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
return { fg: '#a14d43', bg: '#f3d8d2' };
};
const formatBranch = (payload) => {
const parts = [];
const branch = typeof payload.branch === 'string' ? payload.branch : '';
if (branch) parts.push(branch);
if (typeof payload.upstream === 'string' && payload.upstream) {
parts.push(payload.upstream);
}
const ahead = Number(payload.ahead || 0);
const behind = Number(payload.behind || 0);
if (ahead || behind) {
parts.push(`+${ahead} / -${behind}`);
}
if (!parts.length && typeof payload.head === 'string' && payload.head) {
parts.push(payload.head);
}
return parts.join(' · ') || 'No branch information';
};
const formatUpdated = (raw) => {
if (typeof raw !== 'string' || !raw) return '--';
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const chipStyle = (status) => {
if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
return { fg: '#9a6a2f', bg: '#f3e1ba' };
};
const asLineNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
return null;
};
const formatRangePart = (label, start, end) => {
if (start === null || end === null) return '';
return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
};
const buildSelectionPayload = (filePath, lines) => {
const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
const rangeParts = [
formatRangePart('old', oldStart, oldEnd),
formatRangePart('new', newStart, newEnd),
].filter(Boolean);
const fileLabel = filePath || 'Selected diff';
const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
return {
kind: 'git_diff_range',
file_path: filePath || fileLabel,
file_label: fileLabel,
range_label: rangeLabel,
label: `${fileLabel} · ${rangeLabel}`,
old_start: oldStart,
old_end: oldEnd,
new_start: newStart,
new_end: newEnd,
};
};
let activeSelectionController = null;
const clearActiveSelection = () => {
if (activeSelectionController) {
const controller = activeSelectionController;
activeSelectionController = null;
controller.clear(false);
}
host.setSelection(null);
};
const renderPatchBody = (target, item) => {
target.innerHTML = '';
target.dataset.noSwipe = '1';
target.style.marginTop = '10px';
target.style.marginLeft = '-12px';
target.style.marginRight = '-12px';
target.style.width = 'calc(100% + 24px)';
target.style.paddingTop = '10px';
target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
target.style.overflow = 'hidden';
target.style.minWidth = '0';
target.style.maxWidth = 'none';
const viewport = document.createElement('div');
viewport.dataset.noSwipe = '1';
viewport.style.width = '100%';
viewport.style.maxWidth = 'none';
viewport.style.minWidth = '0';
viewport.style.overflowX = 'auto';
viewport.style.overflowY = 'hidden';
viewport.style.touchAction = 'auto';
viewport.style.overscrollBehavior = 'contain';
viewport.style.webkitOverflowScrolling = 'touch';
viewport.style.scrollbarWidth = 'thin';
viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
const diffText = typeof item?.diff === 'string' ? item.diff : '';
const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
if (!diffText && diffLines.length === 0) {
const message = document.createElement('div');
message.textContent = 'No line diff available for this path.';
message.style.fontSize = '0.76rem';
message.style.lineHeight = '1.4';
message.style.color = '#9a7b68';
message.style.fontWeight = '600';
target.appendChild(message);
return;
}
const block = document.createElement('div');
block.dataset.noSwipe = '1';
block.style.display = 'grid';
block.style.gap = '0';
block.style.padding = '0';
block.style.borderRadius = '0';
block.style.background = 'rgba(255,255,255,0.58)';
block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
block.style.borderLeft = '0';
block.style.borderRight = '0';
block.style.fontFamily =
"var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
block.style.fontSize = '0.64rem';
block.style.lineHeight = '1.45';
block.style.color = '#5f4a3f';
block.style.width = 'max-content';
block.style.minWidth = '100%';
const selectableLines = [];
let localSelection = null;
let localAnchorIndex = null;
const setSelectedState = (entry, selected) => {
if (selected) entry.lineEl.dataset.selected = 'true';
else delete entry.lineEl.dataset.selected;
};
const controller = {
clear(publish = true) {
localSelection = null;
localAnchorIndex = null;
for (const entry of selectableLines) setSelectedState(entry, false);
if (publish) {
if (activeSelectionController === controller) activeSelectionController = null;
host.setSelection(null);
}
},
};
const applySelection = (startIndex, endIndex) => {
const lower = Math.min(startIndex, endIndex);
const upper = Math.max(startIndex, endIndex);
localSelection = { startIndex: lower, endIndex: upper };
for (const [index, entry] of selectableLines.entries()) {
setSelectedState(entry, index >= lower && index <= upper);
}
if (activeSelectionController && activeSelectionController !== controller) {
activeSelectionController.clear(false);
}
activeSelectionController = controller;
host.setSelection(buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
);
};
const handleSelectableLine = (index) => {
if (!localSelection) {
localAnchorIndex = index;
applySelection(index, index);
return;
}
const singleLine = localSelection.startIndex === localSelection.endIndex;
if (singleLine) {
const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
if (index === anchorIndex) {
controller.clear(true);
return;
}
applySelection(anchorIndex, index);
localAnchorIndex = null;
return;
}
localAnchorIndex = index;
applySelection(index, index);
};
const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
const entry = {
lineEl,
oldNumber: asLineNumber(oldNumber),
newNumber: asLineNumber(newNumber),
};
const index = selectableLines.push(entry) - 1;
lineEl.dataset.selectable = 'true';
lineEl.tabIndex = 0;
lineEl.setAttribute('role', 'button');
lineEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleSelectableLine(index);
});
lineEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handleSelectableLine(index);
});
};
if (diffLines.length > 0) {
for (const row of diffLines) {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const numberEl = document.createElement('span');
const lineNumber =
typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
? String(row.line_number)
: '';
numberEl.textContent = lineNumber;
numberEl.style.minWidth = '2.2em';
numberEl.style.textAlign = 'right';
numberEl.style.color = '#8c7464';
numberEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = typeof row?.text === 'string' ? row.text : '';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
const kind = typeof row?.kind === 'string' ? row.kind : '';
if (kind === 'added') {
lineEl.style.color = '#0c3f12';
lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#6d0d08';
lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
} else {
lineEl.style.color = '#5f4a3f';
}
lineEl.append(numberEl, textEl);
block.appendChild(lineEl);
}
} else {
const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const oldEl = document.createElement('span');
oldEl.textContent = oldNumber ? String(oldNumber) : '';
oldEl.style.minWidth = '2.4em';
oldEl.style.textAlign = 'right';
oldEl.style.color = '#8c7464';
oldEl.style.opacity = '0.92';
const newEl = document.createElement('span');
newEl.textContent = newNumber ? String(newNumber) : '';
newEl.style.minWidth = '2.4em';
newEl.style.textAlign = 'right';
newEl.style.color = '#8c7464';
newEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = line || ' ';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
if (kind === 'hunk') {
lineEl.style.color = '#6c523f';
lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
lineEl.style.fontWeight = '800';
} else if (kind === 'added') {
lineEl.style.color = '#0f4515';
lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#74110a';
lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
} else if (kind === 'context') {
lineEl.style.color = '#6f5b4d';
lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
} else if (kind === 'meta') {
lineEl.style.color = '#725c4f';
lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
} else if (kind === 'note') {
lineEl.style.color = '#8a6f5c';
lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
lineEl.style.fontStyle = 'italic';
}
lineEl.append(oldEl, newEl, textEl);
if (kind === 'added' || kind === 'removed' || kind === 'context') {
registerSelectableLine(lineEl, oldNumber, newNumber);
}
return lineEl;
};
const parseHunkHeader = (line) => {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!match) return null;
return {
oldLine: Number(match[1] || '0'),
newLine: Number(match[3] || '0'),
};
};
const prelude = [];
const hunks = [];
let currentHunk = null;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
if (currentHunk) hunks.push(currentHunk);
currentHunk = { header: line, lines: [] };
continue;
}
if (currentHunk) {
currentHunk.lines.push(line);
} else {
prelude.push(line);
}
}
if (currentHunk) hunks.push(currentHunk);
for (const line of prelude) {
let kind = 'meta';
if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
else if (line.startsWith(' ')) kind = 'context';
block.appendChild(makePatchLine(line, kind));
}
for (const hunk of hunks) {
const section = document.createElement('section');
section.style.display = 'grid';
section.style.gap = '0';
section.style.marginTop = block.childNodes.length ? '10px' : '0';
section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
section.appendChild(makePatchLine(hunk.header, 'hunk'));
const parsed = parseHunkHeader(hunk.header);
let oldLine = parsed ? parsed.oldLine : 0;
let newLine = parsed ? parsed.newLine : 0;
for (const line of hunk.lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
section.appendChild(makePatchLine(line, 'added', '', newLine));
newLine += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
oldLine += 1;
} else if (line.startsWith('\\')) {
section.appendChild(makePatchLine(line, 'note'));
} else if (line.startsWith('+++') || line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'meta'));
} else {
const oldNumber = oldLine ? oldLine : '';
const newNumber = newLine ? newLine : '';
section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
oldLine += 1;
newLine += 1;
}
}
block.appendChild(section);
}
}
viewport.appendChild(block);
target.appendChild(viewport);
if (item?.diff_truncated) {
const note = document.createElement('div');
note.textContent = 'Diff truncated for readability.';
note.style.marginTop = '8px';
note.style.fontSize = '0.72rem';
note.style.lineHeight = '1.35';
note.style.color = '#9a7b68';
note.style.fontWeight = '600';
target.appendChild(note);
}
};
const renderFiles = (items) => {
clearActiveSelection();
filesEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Working tree clean.';
empty.style.fontSize = '0.92rem';
empty.style.lineHeight = '1.4';
empty.style.color = '#7d8f73';
empty.style.fontWeight = '700';
empty.style.padding = '12px';
empty.style.borderRadius = '12px';
empty.style.background = 'rgba(223, 233, 216, 0.55)';
empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
filesEl.appendChild(empty);
return;
}
for (const item of items) {
const row = document.createElement('div');
row.style.display = 'block';
row.style.minWidth = '0';
row.style.maxWidth = '100%';
row.style.padding = '0';
row.style.borderRadius = '0';
row.style.background = 'transparent';
row.style.border = '0';
row.style.boxShadow = 'none';
const summaryButton = document.createElement('button');
summaryButton.type = 'button';
summaryButton.style.display = 'flex';
summaryButton.style.alignItems = 'flex-start';
summaryButton.style.justifyContent = 'space-between';
summaryButton.style.gap = '8px';
summaryButton.style.width = '100%';
summaryButton.style.minWidth = '0';
summaryButton.style.padding = '0';
summaryButton.style.margin = '0';
summaryButton.style.border = '0';
summaryButton.style.background = 'transparent';
summaryButton.style.textAlign = 'left';
summaryButton.style.cursor = 'pointer';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'flex-start';
left.style.gap = '8px';
left.style.minWidth = '0';
left.style.flex = '1 1 auto';
const chip = document.createElement('span');
const chipTone = chipStyle(String(item?.status || 'M'));
chip.textContent = String(item?.status || 'M');
chip.style.fontSize = '0.72rem';
chip.style.lineHeight = '1.1';
chip.style.fontWeight = '800';
chip.style.color = chipTone.fg;
chip.style.background = chipTone.bg;
chip.style.padding = '4px 7px';
chip.style.borderRadius = '999px';
chip.style.flex = '0 0 auto';
const pathWrap = document.createElement('div');
pathWrap.style.minWidth = '0';
const pathEl = document.createElement('div');
pathEl.textContent = String(item?.path || '--');
pathEl.style.fontSize = '0.92rem';
pathEl.style.lineHeight = '1.3';
pathEl.style.fontWeight = '700';
pathEl.style.color = '#65483a';
pathEl.style.wordBreak = 'break-word';
const detailEl = document.createElement('div');
detailEl.style.marginTop = '3px';
detailEl.style.fontSize = '0.77rem';
detailEl.style.lineHeight = '1.35';
detailEl.style.color = '#9a7b68';
const insertions = Number(item?.insertions || 0);
const deletions = Number(item?.deletions || 0);
detailEl.textContent = insertions || deletions
? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
: 'No line diff';
pathWrap.append(pathEl, detailEl);
left.append(chip, pathWrap);
const toggle = document.createElement('span');
toggle.setAttribute('aria-hidden', 'true');
toggle.style.fontSize = '0.95rem';
toggle.style.lineHeight = '1';
toggle.style.fontWeight = '800';
toggle.style.color = '#9a7b68';
toggle.style.whiteSpace = 'nowrap';
toggle.style.flex = '0 0 auto';
toggle.style.paddingTop = '1px';
const body = document.createElement('div');
body.hidden = true;
body.style.width = '100%';
body.style.maxWidth = '100%';
body.style.minWidth = '0';
renderPatchBody(body, item);
const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
const setExpanded = (expanded) => {
body.hidden = !expanded;
summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.textContent = expanded ? '▴' : '▾';
};
setExpanded(false);
summaryButton.addEventListener('click', () => {
setExpanded(body.hidden);
});
summaryButton.append(left, toggle);
row.append(summaryButton, body);
filesEl.appendChild(row);
}
};
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const render = (payload) => {
subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
branchEl.textContent = formatBranch(payload);
changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
const label = payload.dirty ? 'Dirty' : 'Clean';
const tone = statusTone(label);
setStatus(label, tone.fg, tone.bg);
renderFiles(payload.files);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: payload.repo_name || null,
repo_path: payload.repo_path || null,
branch: payload.branch || null,
upstream: payload.upstream || null,
ahead: Number(payload.ahead || 0),
behind: Number(payload.behind || 0),
dirty: Boolean(payload.dirty),
changed_files: Number(payload.changed_files || 0),
staged_files: Number(payload.staged_files || 0),
unstaged_files: Number(payload.unstaged_files || 0),
untracked_files: Number(payload.untracked_files || 0),
insertions: Number(payload.insertions || 0),
deletions: Number(payload.deletions || 0),
files: Array.isArray(payload.files)
? payload.files.map((item) => ({
path: item?.path || null,
status: item?.status || null,
insertions: Number(item?.insertions || 0),
deletions: Number(item?.deletions || 0),
diff_available: Boolean(item?.diff_available),
diff_truncated: Boolean(item?.diff_truncated),
}))
: [],
generated_at: payload.generated_at || null,
});
};
const renderError = (message) => {
clearActiveSelection();
subtitleEl.textContent = subtitle || 'Git repo';
branchEl.textContent = 'Unable to load repo diff';
changedEl.textContent = '--';
stagingEl.textContent = '--';
untrackedEl.textContent = '--';
upstreamEl.textContent = '--';
plusEl.textContent = '+--';
minusEl.textContent = '- --';
updatedEl.textContent = message;
const tone = statusTone('Unavailable');
setStatus('Unavailable', tone.fg, tone.bg);
filesEl.innerHTML = '';
const error = document.createElement('div');
error.textContent = message;
error.style.fontSize = '0.88rem';
error.style.lineHeight = '1.4';
error.style.color = '#a45b51';
error.style.fontWeight = '700';
error.style.padding = '12px';
error.style.borderRadius = '12px';
error.style.background = 'rgba(243, 216, 210, 0.55)';
error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
filesEl.appendChild(error);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: null,
repo_path: null,
dirty: null,
error: message,
});
};
const loadPayload = async () => {
if (!configuredToolName) throw new Error('Missing template_state.tool_name');
if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await host.callToolAsync(
configuredToolName,
rawToolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
if (rawContent) {
if (rawContent.includes('(truncated,')) {
throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
}
const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
if (!normalizedContent.startsWith('{')) {
throw new Error(rawContent);
}
try {
const parsed = JSON.parse(normalizedContent);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Tool returned invalid JSON: ${detail}`);
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
try {
const payload = await loadPayload();
render(payload);
} catch (error) {
renderError(String(error));
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -162,708 +162,3 @@
<div data-git-files></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-git-subtitle]');
const branchEl = root.querySelector('[data-git-branch]');
const statusEl = root.querySelector('[data-git-status]');
const changedEl = root.querySelector('[data-git-changed]');
const stagingEl = root.querySelector('[data-git-staging]');
const untrackedEl = root.querySelector('[data-git-untracked]');
const upstreamEl = root.querySelector('[data-git-upstream]');
const plusEl = root.querySelector('[data-git-plus]');
const minusEl = root.querySelector('[data-git-minus]');
const updatedEl = root.querySelector('[data-git-updated]');
const filesEl = root.querySelector('[data-git-files]');
if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
const setStatus = (label, fg, bg) => {
statusEl.textContent = label;
statusEl.style.color = fg;
statusEl.style.background = bg;
statusEl.style.padding = '3px 7px';
statusEl.style.borderRadius = '999px';
};
const statusTone = (value) => {
if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
return { fg: '#a14d43', bg: '#f3d8d2' };
};
const formatBranch = (payload) => {
const parts = [];
const branch = typeof payload.branch === 'string' ? payload.branch : '';
if (branch) parts.push(branch);
if (typeof payload.upstream === 'string' && payload.upstream) {
parts.push(payload.upstream);
}
const ahead = Number(payload.ahead || 0);
const behind = Number(payload.behind || 0);
if (ahead || behind) {
parts.push(`+${ahead} / -${behind}`);
}
if (!parts.length && typeof payload.head === 'string' && payload.head) {
parts.push(payload.head);
}
return parts.join(' · ') || 'No branch information';
};
const formatUpdated = (raw) => {
if (typeof raw !== 'string' || !raw) return '--';
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const chipStyle = (status) => {
if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
return { fg: '#9a6a2f', bg: '#f3e1ba' };
};
const asLineNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
return null;
};
const formatRangePart = (label, start, end) => {
if (start === null || end === null) return '';
return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
};
const buildSelectionPayload = (filePath, lines) => {
const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
const rangeParts = [
formatRangePart('old', oldStart, oldEnd),
formatRangePart('new', newStart, newEnd),
].filter(Boolean);
const fileLabel = filePath || 'Selected diff';
const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
return {
kind: 'git_diff_range',
file_path: filePath || fileLabel,
file_label: fileLabel,
range_label: rangeLabel,
label: `${fileLabel} · ${rangeLabel}`,
old_start: oldStart,
old_end: oldEnd,
new_start: newStart,
new_end: newEnd,
};
};
let activeSelectionController = null;
const clearActiveSelection = () => {
if (activeSelectionController) {
const controller = activeSelectionController;
activeSelectionController = null;
controller.clear(false);
}
window.__nanobotSetCardSelection?.(script, null);
};
const renderPatchBody = (target, item) => {
target.innerHTML = '';
target.dataset.noSwipe = '1';
target.style.marginTop = '10px';
target.style.marginLeft = '-12px';
target.style.marginRight = '-12px';
target.style.width = 'calc(100% + 24px)';
target.style.paddingTop = '10px';
target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
target.style.overflow = 'hidden';
target.style.minWidth = '0';
target.style.maxWidth = 'none';
const viewport = document.createElement('div');
viewport.dataset.noSwipe = '1';
viewport.style.width = '100%';
viewport.style.maxWidth = 'none';
viewport.style.minWidth = '0';
viewport.style.overflowX = 'auto';
viewport.style.overflowY = 'hidden';
viewport.style.touchAction = 'auto';
viewport.style.overscrollBehavior = 'contain';
viewport.style.webkitOverflowScrolling = 'touch';
viewport.style.scrollbarWidth = 'thin';
viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
const diffText = typeof item?.diff === 'string' ? item.diff : '';
const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
if (!diffText && diffLines.length === 0) {
const message = document.createElement('div');
message.textContent = 'No line diff available for this path.';
message.style.fontSize = '0.76rem';
message.style.lineHeight = '1.4';
message.style.color = '#9a7b68';
message.style.fontWeight = '600';
target.appendChild(message);
return;
}
const block = document.createElement('div');
block.dataset.noSwipe = '1';
block.style.display = 'grid';
block.style.gap = '0';
block.style.padding = '0';
block.style.borderRadius = '0';
block.style.background = 'rgba(255,255,255,0.58)';
block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
block.style.borderLeft = '0';
block.style.borderRight = '0';
block.style.fontFamily =
"var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
block.style.fontSize = '0.64rem';
block.style.lineHeight = '1.45';
block.style.color = '#5f4a3f';
block.style.width = 'max-content';
block.style.minWidth = '100%';
const selectableLines = [];
let localSelection = null;
let localAnchorIndex = null;
const setSelectedState = (entry, selected) => {
if (selected) entry.lineEl.dataset.selected = 'true';
else delete entry.lineEl.dataset.selected;
};
const controller = {
clear(publish = true) {
localSelection = null;
localAnchorIndex = null;
for (const entry of selectableLines) setSelectedState(entry, false);
if (publish) {
if (activeSelectionController === controller) activeSelectionController = null;
window.__nanobotSetCardSelection?.(script, null);
}
},
};
const applySelection = (startIndex, endIndex) => {
const lower = Math.min(startIndex, endIndex);
const upper = Math.max(startIndex, endIndex);
localSelection = { startIndex: lower, endIndex: upper };
for (const [index, entry] of selectableLines.entries()) {
setSelectedState(entry, index >= lower && index <= upper);
}
if (activeSelectionController && activeSelectionController !== controller) {
activeSelectionController.clear(false);
}
activeSelectionController = controller;
window.__nanobotSetCardSelection?.(
script,
buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
);
};
const handleSelectableLine = (index) => {
if (!localSelection) {
localAnchorIndex = index;
applySelection(index, index);
return;
}
const singleLine = localSelection.startIndex === localSelection.endIndex;
if (singleLine) {
const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
if (index === anchorIndex) {
controller.clear(true);
return;
}
applySelection(anchorIndex, index);
localAnchorIndex = null;
return;
}
localAnchorIndex = index;
applySelection(index, index);
};
const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
const entry = {
lineEl,
oldNumber: asLineNumber(oldNumber),
newNumber: asLineNumber(newNumber),
};
const index = selectableLines.push(entry) - 1;
lineEl.dataset.selectable = 'true';
lineEl.tabIndex = 0;
lineEl.setAttribute('role', 'button');
lineEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleSelectableLine(index);
});
lineEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handleSelectableLine(index);
});
};
if (diffLines.length > 0) {
for (const row of diffLines) {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const numberEl = document.createElement('span');
const lineNumber =
typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
? String(row.line_number)
: '';
numberEl.textContent = lineNumber;
numberEl.style.minWidth = '2.2em';
numberEl.style.textAlign = 'right';
numberEl.style.color = '#8c7464';
numberEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = typeof row?.text === 'string' ? row.text : '';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
const kind = typeof row?.kind === 'string' ? row.kind : '';
if (kind === 'added') {
lineEl.style.color = '#0c3f12';
lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#6d0d08';
lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
} else {
lineEl.style.color = '#5f4a3f';
}
lineEl.append(numberEl, textEl);
block.appendChild(lineEl);
}
} else {
const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const oldEl = document.createElement('span');
oldEl.textContent = oldNumber ? String(oldNumber) : '';
oldEl.style.minWidth = '2.4em';
oldEl.style.textAlign = 'right';
oldEl.style.color = '#8c7464';
oldEl.style.opacity = '0.92';
const newEl = document.createElement('span');
newEl.textContent = newNumber ? String(newNumber) : '';
newEl.style.minWidth = '2.4em';
newEl.style.textAlign = 'right';
newEl.style.color = '#8c7464';
newEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = line || ' ';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
if (kind === 'hunk') {
lineEl.style.color = '#6c523f';
lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
lineEl.style.fontWeight = '800';
} else if (kind === 'added') {
lineEl.style.color = '#0f4515';
lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#74110a';
lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
} else if (kind === 'context') {
lineEl.style.color = '#6f5b4d';
lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
} else if (kind === 'meta') {
lineEl.style.color = '#725c4f';
lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
} else if (kind === 'note') {
lineEl.style.color = '#8a6f5c';
lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
lineEl.style.fontStyle = 'italic';
}
lineEl.append(oldEl, newEl, textEl);
if (kind === 'added' || kind === 'removed' || kind === 'context') {
registerSelectableLine(lineEl, oldNumber, newNumber);
}
return lineEl;
};
const parseHunkHeader = (line) => {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!match) return null;
return {
oldLine: Number(match[1] || '0'),
newLine: Number(match[3] || '0'),
};
};
const prelude = [];
const hunks = [];
let currentHunk = null;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
if (currentHunk) hunks.push(currentHunk);
currentHunk = { header: line, lines: [] };
continue;
}
if (currentHunk) {
currentHunk.lines.push(line);
} else {
prelude.push(line);
}
}
if (currentHunk) hunks.push(currentHunk);
for (const line of prelude) {
let kind = 'meta';
if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
else if (line.startsWith(' ')) kind = 'context';
block.appendChild(makePatchLine(line, kind));
}
for (const hunk of hunks) {
const section = document.createElement('section');
section.style.display = 'grid';
section.style.gap = '0';
section.style.marginTop = block.childNodes.length ? '10px' : '0';
section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
section.appendChild(makePatchLine(hunk.header, 'hunk'));
const parsed = parseHunkHeader(hunk.header);
let oldLine = parsed ? parsed.oldLine : 0;
let newLine = parsed ? parsed.newLine : 0;
for (const line of hunk.lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
section.appendChild(makePatchLine(line, 'added', '', newLine));
newLine += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
oldLine += 1;
} else if (line.startsWith('\\')) {
section.appendChild(makePatchLine(line, 'note'));
} else if (line.startsWith('+++') || line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'meta'));
} else {
const oldNumber = oldLine ? oldLine : '';
const newNumber = newLine ? newLine : '';
section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
oldLine += 1;
newLine += 1;
}
}
block.appendChild(section);
}
}
viewport.appendChild(block);
target.appendChild(viewport);
if (item?.diff_truncated) {
const note = document.createElement('div');
note.textContent = 'Diff truncated for readability.';
note.style.marginTop = '8px';
note.style.fontSize = '0.72rem';
note.style.lineHeight = '1.35';
note.style.color = '#9a7b68';
note.style.fontWeight = '600';
target.appendChild(note);
}
};
const renderFiles = (items) => {
clearActiveSelection();
filesEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Working tree clean.';
empty.style.fontSize = '0.92rem';
empty.style.lineHeight = '1.4';
empty.style.color = '#7d8f73';
empty.style.fontWeight = '700';
empty.style.padding = '12px';
empty.style.borderRadius = '12px';
empty.style.background = 'rgba(223, 233, 216, 0.55)';
empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
filesEl.appendChild(empty);
return;
}
for (const item of items) {
const row = document.createElement('div');
row.style.display = 'block';
row.style.minWidth = '0';
row.style.maxWidth = '100%';
row.style.padding = '0';
row.style.borderRadius = '0';
row.style.background = 'transparent';
row.style.border = '0';
row.style.boxShadow = 'none';
const summaryButton = document.createElement('button');
summaryButton.type = 'button';
summaryButton.style.display = 'flex';
summaryButton.style.alignItems = 'flex-start';
summaryButton.style.justifyContent = 'space-between';
summaryButton.style.gap = '8px';
summaryButton.style.width = '100%';
summaryButton.style.minWidth = '0';
summaryButton.style.padding = '0';
summaryButton.style.margin = '0';
summaryButton.style.border = '0';
summaryButton.style.background = 'transparent';
summaryButton.style.textAlign = 'left';
summaryButton.style.cursor = 'pointer';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'flex-start';
left.style.gap = '8px';
left.style.minWidth = '0';
left.style.flex = '1 1 auto';
const chip = document.createElement('span');
const chipTone = chipStyle(String(item?.status || 'M'));
chip.textContent = String(item?.status || 'M');
chip.style.fontSize = '0.72rem';
chip.style.lineHeight = '1.1';
chip.style.fontWeight = '800';
chip.style.color = chipTone.fg;
chip.style.background = chipTone.bg;
chip.style.padding = '4px 7px';
chip.style.borderRadius = '999px';
chip.style.flex = '0 0 auto';
const pathWrap = document.createElement('div');
pathWrap.style.minWidth = '0';
const pathEl = document.createElement('div');
pathEl.textContent = String(item?.path || '--');
pathEl.style.fontSize = '0.92rem';
pathEl.style.lineHeight = '1.3';
pathEl.style.fontWeight = '700';
pathEl.style.color = '#65483a';
pathEl.style.wordBreak = 'break-word';
const detailEl = document.createElement('div');
detailEl.style.marginTop = '3px';
detailEl.style.fontSize = '0.77rem';
detailEl.style.lineHeight = '1.35';
detailEl.style.color = '#9a7b68';
const insertions = Number(item?.insertions || 0);
const deletions = Number(item?.deletions || 0);
detailEl.textContent = insertions || deletions
? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
: 'No line diff';
pathWrap.append(pathEl, detailEl);
left.append(chip, pathWrap);
const toggle = document.createElement('span');
toggle.setAttribute('aria-hidden', 'true');
toggle.style.fontSize = '0.95rem';
toggle.style.lineHeight = '1';
toggle.style.fontWeight = '800';
toggle.style.color = '#9a7b68';
toggle.style.whiteSpace = 'nowrap';
toggle.style.flex = '0 0 auto';
toggle.style.paddingTop = '1px';
const body = document.createElement('div');
body.hidden = true;
body.style.width = '100%';
body.style.maxWidth = '100%';
body.style.minWidth = '0';
renderPatchBody(body, item);
const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
const setExpanded = (expanded) => {
body.hidden = !expanded;
summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.textContent = expanded ? '▴' : '▾';
};
setExpanded(false);
summaryButton.addEventListener('click', () => {
setExpanded(body.hidden);
});
summaryButton.append(left, toggle);
row.append(summaryButton, body);
filesEl.appendChild(row);
}
};
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const render = (payload) => {
subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
branchEl.textContent = formatBranch(payload);
changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
const label = payload.dirty ? 'Dirty' : 'Clean';
const tone = statusTone(label);
setStatus(label, tone.fg, tone.bg);
renderFiles(payload.files);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: payload.repo_name || null,
repo_path: payload.repo_path || null,
branch: payload.branch || null,
upstream: payload.upstream || null,
ahead: Number(payload.ahead || 0),
behind: Number(payload.behind || 0),
dirty: Boolean(payload.dirty),
changed_files: Number(payload.changed_files || 0),
staged_files: Number(payload.staged_files || 0),
unstaged_files: Number(payload.unstaged_files || 0),
untracked_files: Number(payload.untracked_files || 0),
insertions: Number(payload.insertions || 0),
deletions: Number(payload.deletions || 0),
files: Array.isArray(payload.files)
? payload.files.map((item) => ({
path: item?.path || null,
status: item?.status || null,
insertions: Number(item?.insertions || 0),
deletions: Number(item?.deletions || 0),
diff_available: Boolean(item?.diff_available),
diff_truncated: Boolean(item?.diff_truncated),
}))
: [],
generated_at: payload.generated_at || null,
});
};
const renderError = (message) => {
clearActiveSelection();
subtitleEl.textContent = subtitle || 'Git repo';
branchEl.textContent = 'Unable to load repo diff';
changedEl.textContent = '--';
stagingEl.textContent = '--';
untrackedEl.textContent = '--';
upstreamEl.textContent = '--';
plusEl.textContent = '+--';
minusEl.textContent = '- --';
updatedEl.textContent = message;
const tone = statusTone('Unavailable');
setStatus('Unavailable', tone.fg, tone.bg);
filesEl.innerHTML = '';
const error = document.createElement('div');
error.textContent = message;
error.style.fontSize = '0.88rem';
error.style.lineHeight = '1.4';
error.style.color = '#a45b51';
error.style.fontWeight = '700';
error.style.padding = '12px';
error.style.borderRadius = '12px';
error.style.background = 'rgba(243, 216, 210, 0.55)';
error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
filesEl.appendChild(error);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: null,
repo_path: null,
dirty: null,
error: message,
});
};
const loadPayload = async () => {
if (!configuredToolName) throw new Error('Missing template_state.tool_name');
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await window.__nanobotCallToolAsync(
configuredToolName,
rawToolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
if (rawContent) {
if (rawContent.includes('(truncated,')) {
throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
}
const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
if (!normalizedContent.startsWith('{')) {
throw new Error(rawContent);
}
try {
const parsed = JSON.parse(normalizedContent);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Tool returned invalid JSON: ${detail}`);
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
try {
const payload = await loadPayload();
render(payload);
} catch (error) {
renderError(String(error));
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
})();
</script>

View file

@ -0,0 +1,258 @@
function clampDigits(raw) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 4;
return Math.max(1, Math.min(4, Math.round(parsed)));
}
function sanitizeValue(raw, maxDigits) {
return String(raw || "")
.replace(/\D+/g, "")
.slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || "")
.replace(/\s+/g, " ")
.trimStart();
}
function isBlankRow(row) {
return !row || (!String(row.value || "").trim() && !String(row.name || "").trim());
}
function normalizeRows(raw, maxDigits) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value, maxDigits),
name: sanitizeName(row.name),
}));
}
function ensureTrailingBlankRow(rows) {
const next = rows.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: "", name: "" });
}
return next;
}
function persistedRows(rows) {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
}
function totalValue(rows) {
return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function normalizeConfig(state) {
const maxDigits = clampDigits(state.max_digits);
return {
leftLabel: String(state.left_label || "Value").trim() || "Value",
rightLabel: String(state.right_label || "Item").trim() || "Item",
totalLabel: String(state.total_label || "Total").trim() || "Total",
totalSuffix: String(state.total_suffix || "").trim(),
maxDigits,
score:
typeof state.score === "number" && Number.isFinite(state.score)
? Math.max(0, Math.min(100, state.score))
: 24,
rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)),
};
}
function configState(config) {
return {
left_label: config.leftLabel,
right_label: config.rightLabel,
total_label: config.totalLabel,
total_suffix: config.totalSuffix,
max_digits: config.maxDigits,
score: config.score,
rows: persistedRows(config.rows),
};
}
function autoscore(config) {
if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) {
return config.score;
}
return persistedRows(config.rows).length ? 24 : 16;
}
export function mount({ root, state, host }) {
const labelsEl = root.querySelector(".list-total-card-ui__labels");
const rowsEl = root.querySelector(".list-total-card-ui__rows");
const statusEl = root.querySelector(".list-total-card-ui__status");
const totalLabelEl = root.querySelector(".list-total-card-ui__total-label");
const totalEl = root.querySelector(".list-total-card-ui__total-value");
if (
!(labelsEl instanceof HTMLElement) ||
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement)
) {
return;
}
const leftLabelEl = labelsEl.children.item(0);
const rightLabelEl = labelsEl.children.item(1);
if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return;
let config = normalizeConfig(state);
let saveTimer = null;
let busy = false;
const clearSaveTimer = () => {
if (saveTimer !== null) {
window.clearTimeout(saveTimer);
saveTimer = null;
}
};
const setStatus = (text, kind) => {
statusEl.textContent = text || "";
statusEl.dataset.kind = kind || "";
};
const publishLiveContent = () => {
host.setLiveContent({
kind: "list_total",
item_count: persistedRows(config.rows).length,
total: totalValue(config.rows),
total_suffix: config.totalSuffix || null,
score: autoscore(config),
});
};
const renderTotal = () => {
const total = totalValue(config.rows);
totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`;
publishLiveContent();
};
const persist = async () => {
clearSaveTimer();
busy = true;
setStatus("Saving", "ok");
try {
const nextState = configState(config);
await host.replaceState(nextState);
config = normalizeConfig(nextState);
setStatus("", "");
} catch (error) {
console.error("List total card save failed", error);
setStatus("Unavailable", "error");
} finally {
busy = false;
render();
}
};
const schedulePersist = () => {
clearSaveTimer();
saveTimer = window.setTimeout(() => {
void persist();
}, 280);
};
const normalizeRowsAfterBlur = () => {
config.rows = ensureTrailingBlankRow(persistedRows(config.rows));
render();
schedulePersist();
};
const renderRows = () => {
rowsEl.innerHTML = "";
config.rows.forEach((row, index) => {
const rowEl = document.createElement("div");
rowEl.className = "list-total-card-ui__row";
const valueInput = document.createElement("input");
valueInput.className = "list-total-card-ui__input list-total-card-ui__value";
valueInput.type = "text";
valueInput.inputMode = "numeric";
valueInput.maxLength = config.maxDigits;
valueInput.placeholder = "0";
valueInput.value = row.value;
valueInput.disabled = busy;
const nameInput = document.createElement("input");
nameInput.className = "list-total-card-ui__input list-total-card-ui__name";
nameInput.type = "text";
nameInput.placeholder = "Item";
nameInput.value = row.name;
nameInput.disabled = busy;
valueInput.addEventListener("input", () => {
config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits);
valueInput.value = config.rows[index].value;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
nameInput.addEventListener("input", () => {
config.rows[index].name = sanitizeName(nameInput.value);
nameInput.value = config.rows[index].name;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
valueInput.addEventListener("blur", normalizeRowsAfterBlur);
nameInput.addEventListener("blur", normalizeRowsAfterBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
};
const render = () => {
leftLabelEl.textContent = config.leftLabel;
rightLabelEl.textContent = config.rightLabel;
totalLabelEl.textContent = config.totalLabel;
renderRows();
renderTotal();
};
host.setRefreshHandler(() => {
config.rows = ensureTrailingBlankRow(config.rows);
render();
});
render();
return {
update({ state: nextState }) {
config = normalizeConfig(nextState);
render();
},
destroy() {
clearSaveTimer();
host.setRefreshHandler(null);
host.setLiveContent(null);
},
};
}

View file

@ -1,336 +1,12 @@
<style>
.list-total-card {
display: grid;
gap: 10px;
color: #4d392d;
}
.list-total-card__labels,
.list-total-card__row,
.list-total-card__total {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.list-total-card__labels {
color: rgba(77, 57, 45, 0.72);
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__rows {
display: grid;
gap: 6px;
}
.list-total-card__input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
border-radius: 0;
background: transparent;
color: #473429;
padding: 4px 0;
outline: none;
box-shadow: none;
}
.list-total-card__input::placeholder {
color: rgba(77, 57, 45, 0.42);
}
.list-total-card__value {
font: 700 0.84rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
.list-total-card__name {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.92rem;
line-height: 1.08;
font-weight: 600;
letter-spacing: -0.008em;
}
.list-total-card__status {
min-height: 0.9rem;
color: #8e3023;
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list-total-card__status[data-kind='ok'] {
color: rgba(77, 57, 45, 0.5);
}
.list-total-card__total {
padding-top: 8px;
border-top: 1px solid rgba(92, 70, 55, 0.18);
color: #35271f;
}
.list-total-card__total-label {
font: 700 0.66rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__total-value {
font: 700 0.98rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
</style>
<div class="list-total-card" data-list-total-card>
<div class="list-total-card__labels">
<div data-list-total-left-label>Value</div>
<div data-list-total-right-label>Item</div>
<div class="list-total-card-ui">
<div class="list-total-card-ui__labels">
<div>Value</div>
<div>Item</div>
</div>
<div class="list-total-card__rows" data-list-total-rows></div>
<div class="list-total-card__status" data-list-total-status></div>
<div class="list-total-card__total">
<div class="list-total-card__total-label" data-list-total-total-label>Total</div>
<div class="list-total-card__total-value" data-list-total-total>0</div>
<div class="list-total-card-ui__rows"></div>
<div class="list-total-card-ui__status"></div>
<div class="list-total-card-ui__total">
<div class="list-total-card-ui__total-label">Total</div>
<div class="list-total-card-ui__total-value">0</div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const cardId = String(root.dataset.cardId || '').trim();
const rowsEl = root.querySelector('[data-list-total-rows]');
const statusEl = root.querySelector('[data-list-total-status]');
const totalEl = root.querySelector('[data-list-total-total]');
const totalLabelEl = root.querySelector('[data-list-total-total-label]');
const leftLabelEl = root.querySelector('[data-list-total-left-label]');
const rightLabelEl = root.querySelector('[data-list-total-right-label]');
if (
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(leftLabelEl instanceof HTMLElement) ||
!(rightLabelEl instanceof HTMLElement)
) {
return;
}
const maxDigits = Math.max(
1,
Math.min(4, Number.isFinite(Number(state.max_digits)) ? Number(state.max_digits) : 4),
);
const totalSuffix = String(state.total_suffix || '').trim();
const leftLabel = String(state.left_label || 'Value').trim() || 'Value';
const rightLabel = String(state.right_label || 'Item').trim() || 'Item';
const totalLabel = String(state.total_label || 'Total').trim() || 'Total';
leftLabelEl.textContent = leftLabel;
rightLabelEl.textContent = rightLabel;
totalLabelEl.textContent = totalLabel;
function sanitizeValue(raw) {
return String(raw || '').replace(/\D+/g, '').slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || '').replace(/\s+/g, ' ').trimStart();
}
function normalizeRows(raw) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function isBlankRow(row) {
return !row || (!String(row.value || '').trim() && !String(row.name || '').trim());
}
function ensureTrailingBlankRow(items) {
const next = items.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: '', name: '' });
}
return next;
}
function persistedRows() {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function computeTotal() {
return persistedRows().reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function updateTotal() {
const total = computeTotal();
totalEl.textContent = `${total.toLocaleString()}${totalSuffix ? totalSuffix : ''}`;
window.__nanobotSetCardLiveContent?.(script, {
kind: 'list_total',
item_count: persistedRows().length,
total,
total_suffix: totalSuffix || null,
score: persistedRows().length ? 24 : 16,
});
}
function setStatus(text, kind) {
statusEl.textContent = text || '';
statusEl.dataset.kind = kind || '';
}
let rows = ensureTrailingBlankRow(normalizeRows(state.rows));
let saveTimer = null;
let inFlightSave = null;
async function persistState() {
if (!cardId) return;
const nextState = {
...state,
left_label: leftLabel,
right_label: rightLabel,
total_label: totalLabel,
total_suffix: totalSuffix,
max_digits: maxDigits,
rows: persistedRows(),
};
try {
setStatus('Saving', 'ok');
inFlightSave = fetch(`/cards/${encodeURIComponent(cardId)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template_state: nextState }),
});
const response = await inFlightSave;
if (!response.ok) {
let message = `save failed (${response.status})`;
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error) {
message = payload.error;
}
} catch (_) {}
throw new Error(message);
}
setStatus('', '');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'save failed', 'error');
} finally {
inFlightSave = null;
}
}
function schedulePersist() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
void persistState();
}, 280);
}
function pruneRows() {
rows = ensureTrailingBlankRow(
rows.filter((row, index) => !isBlankRow(row) || index === rows.length - 1),
);
}
function renderRows() {
rowsEl.innerHTML = '';
rows.forEach((row, index) => {
const rowEl = document.createElement('div');
rowEl.className = 'list-total-card__row';
const valueInput = document.createElement('input');
valueInput.className = 'list-total-card__input list-total-card__value';
valueInput.type = 'text';
valueInput.inputMode = 'numeric';
valueInput.maxLength = maxDigits;
valueInput.placeholder = '0';
valueInput.value = row.value;
const nameInput = document.createElement('input');
nameInput.className = 'list-total-card__input list-total-card__name';
nameInput.type = 'text';
nameInput.placeholder = 'Item';
nameInput.value = row.name;
valueInput.addEventListener('input', () => {
rows[index].value = sanitizeValue(valueInput.value);
valueInput.value = rows[index].value;
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
nameInput.addEventListener('input', () => {
rows[index].name = sanitizeName(nameInput.value);
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
const handleBlur = () => {
rows[index].value = sanitizeValue(valueInput.value);
rows[index].name = sanitizeName(nameInput.value);
const nextRows = ensureTrailingBlankRow(
rows.filter((candidate, candidateIndex) => !isBlankRow(candidate) || candidateIndex === rows.length - 1),
);
const changed = JSON.stringify(nextRows) !== JSON.stringify(rows);
rows = nextRows;
if (changed) {
renderRows();
} else {
updateTotal();
}
schedulePersist();
};
valueInput.addEventListener('blur', handleBlur);
nameInput.addEventListener('blur', handleBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
updateTotal();
}
window.__nanobotSetCardRefresh?.(script, () => {
pruneRows();
renderRows();
});
renderRows();
})();
</script>

View file

@ -0,0 +1,268 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-usage-subtitle]');
const statusEl = root.querySelector('[data-usage-status]');
const updatedEl = root.querySelector('[data-usage-updated]');
const gridEl = root.querySelector('[data-usage-grid]');
const monthSectionEl = root.querySelector('[data-usage-month-section]');
const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
const power24hEl = root.querySelector('[data-usage-power-24h]');
const window24hEl = root.querySelector('[data-usage-window-24h]');
const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
const powerMonthEl = root.querySelector('[data-usage-power-month]');
const windowMonthEl = root.querySelector('[data-usage-window-month]');
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName24h = typeof state.tool_name_24h === 'string'
? state.tool_name_24h.trim()
: typeof state.tool_name === 'string'
? state.tool_name.trim()
: '';
const configuredToolNameMonth = typeof state.tool_name_month === 'string'
? state.tool_name_month.trim()
: '';
const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
? state.tool_arguments_24h
: state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
? state.tool_arguments_month
: {};
const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
const hasMonthSource = Boolean(
sourceMonth ||
configuredToolNameMonth ||
Object.keys(rawToolArgumentsMonth).length,
);
if (!hasMonthSource) {
monthSectionEl.style.display = 'none';
gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
}
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const parseLocalTimestamp = (raw) => {
if (typeof raw !== 'string' || !raw.trim()) return null;
const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
if (!match) return null;
const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const formatRangeLabel = (payload, fallbackLabel) => {
const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
const start = parseLocalTimestamp(startRaw);
const end = parseLocalTimestamp(endRaw);
if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
return fallbackLabel;
}
return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
};
const renderSection = (elements, payload, fallbackLabel) => {
const tokens = Number(payload?.total_tokens_processed);
const kwh = Number(payload?.total_ups_kwh_in_range);
const localCost = Number(payload?.local_cost_usd_in_range);
elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
elements.power.textContent =
Number.isFinite(kwh) && Number.isFinite(localCost)
? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
: Number.isFinite(kwh)
? `${kwhFormatter.format(kwh)} kWh`
: '--';
elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
};
const blankSection = (elements, fallbackLabel) => {
elements.tokens.textContent = '--';
elements.power.textContent = '--';
elements.window.textContent = fallbackLabel;
};
const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
const buildLegacyExecCommand = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
if (!relativeScript) return '';
const params = new URLSearchParams(queryPart);
const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
};
const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
if (toolName) {
return {
toolName,
toolArguments,
};
}
const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
if (!legacyCommand) return null;
return {
toolName: 'exec',
toolArguments: { command: legacyCommand },
};
};
const loadPayload = async (toolCall) => {
if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await host.callToolAsync(
toolCall.toolName,
toolCall.toolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
const toolCallMonth = hasMonthSource
? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
: null;
const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
const results = await Promise.allSettled(jobs);
const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
if (twentyFourHour) {
renderSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
twentyFourHour,
'Last 24 hours',
);
} else {
blankSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
'Last 24 hours',
);
}
if (hasMonthSource && month) {
renderSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
month,
'This month',
);
} else if (hasMonthSource) {
blankSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
'This month',
);
}
const successCount = [twentyFourHour, month].filter(Boolean).length;
const expectedCount = hasMonthSource ? 2 : 1;
if (successCount === expectedCount) {
setStatus('Live', 'var(--theme-status-live)');
} else if (successCount === 1) {
setStatus('Partial', 'var(--theme-status-warning)');
} else {
setStatus('Unavailable', 'var(--theme-status-danger)');
}
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: statusEl.textContent || null,
updated_at: updatedText,
last_24h: twentyFourHour
? {
total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
range_start_local: twentyFourHour.range_start_local || null,
range_end_local: twentyFourHour.range_end_local || null,
}
: null,
this_month: month
? {
total_tokens_processed: Number(month.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
range_start_local: month.range_start_local || null,
range_end_local: month.range_end_local || null,
}
: null,
});
} catch (error) {
const errorText = String(error);
blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
if (hasMonthSource) {
blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
}
updatedEl.textContent = errorText;
setStatus('Unavailable', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
last_24h: null,
this_month: null,
error: errorText,
});
}
};
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,282 +1,30 @@
<div data-litellm-usage-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-litellm-usage-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:10px;">
<div data-usage-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-usage-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-usage-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-usage-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div data-usage-grid style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:12px;">
<section style="min-width:0;">
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Last 24h</div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:var(--theme-card-neutral-muted);">Last 24h</div>
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
<span data-usage-tokens-24h style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.22rem;">tokens</span>
</div>
<div data-usage-power-24h style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
<div data-usage-window-24h style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
<div data-usage-power-24h style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:var(--theme-card-neutral-text); font-weight:700;">--</div>
<div data-usage-window-24h style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:var(--theme-card-neutral-muted);">--</div>
</section>
<section data-usage-month-section style="min-width:0;">
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">This Month</div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:var(--theme-card-neutral-muted);">This Month</div>
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
<span data-usage-tokens-month style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.22rem;">tokens</span>
</div>
<div data-usage-power-month style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
<div data-usage-window-month style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
<div data-usage-power-month style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:var(--theme-card-neutral-text); font-weight:700;">--</div>
<div data-usage-window-month style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:var(--theme-card-neutral-muted);">--</div>
</section>
</div>
<div style="margin-top:10px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-usage-updated>--</span></div>
<div style="margin-top:10px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">Updated <span data-usage-updated>--</span></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-usage-subtitle]');
const statusEl = root.querySelector('[data-usage-status]');
const updatedEl = root.querySelector('[data-usage-updated]');
const gridEl = root.querySelector('[data-usage-grid]');
const monthSectionEl = root.querySelector('[data-usage-month-section]');
const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
const power24hEl = root.querySelector('[data-usage-power-24h]');
const window24hEl = root.querySelector('[data-usage-window-24h]');
const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
const powerMonthEl = root.querySelector('[data-usage-power-month]');
const windowMonthEl = root.querySelector('[data-usage-window-month]');
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName24h = typeof state.tool_name_24h === 'string'
? state.tool_name_24h.trim()
: typeof state.tool_name === 'string'
? state.tool_name.trim()
: '';
const configuredToolNameMonth = typeof state.tool_name_month === 'string'
? state.tool_name_month.trim()
: '';
const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
? state.tool_arguments_24h
: state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
? state.tool_arguments_month
: {};
const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
const hasMonthSource = Boolean(
sourceMonth ||
configuredToolNameMonth ||
Object.keys(rawToolArgumentsMonth).length,
);
if (!hasMonthSource) {
monthSectionEl.style.display = 'none';
gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
}
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const parseLocalTimestamp = (raw) => {
if (typeof raw !== 'string' || !raw.trim()) return null;
const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
if (!match) return null;
const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const formatRangeLabel = (payload, fallbackLabel) => {
const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
const start = parseLocalTimestamp(startRaw);
const end = parseLocalTimestamp(endRaw);
if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
return fallbackLabel;
}
return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
};
const renderSection = (elements, payload, fallbackLabel) => {
const tokens = Number(payload?.total_tokens_processed);
const kwh = Number(payload?.total_ups_kwh_in_range);
const localCost = Number(payload?.local_cost_usd_in_range);
elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
elements.power.textContent =
Number.isFinite(kwh) && Number.isFinite(localCost)
? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
: Number.isFinite(kwh)
? `${kwhFormatter.format(kwh)} kWh`
: '--';
elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
};
const blankSection = (elements, fallbackLabel) => {
elements.tokens.textContent = '--';
elements.power.textContent = '--';
elements.window.textContent = fallbackLabel;
};
const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
const buildLegacyExecCommand = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
if (!relativeScript) return '';
const params = new URLSearchParams(queryPart);
const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
};
const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
if (toolName) {
return {
toolName,
toolArguments,
};
}
const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
if (!legacyCommand) return null;
return {
toolName: 'exec',
toolArguments: { command: legacyCommand },
};
};
const loadPayload = async (toolCall) => {
if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await window.__nanobotCallToolAsync(
toolCall.toolName,
toolCall.toolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
setStatus('Refreshing', '#6b7280');
try {
const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
const toolCallMonth = hasMonthSource
? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
: null;
const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
const results = await Promise.allSettled(jobs);
const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
if (twentyFourHour) {
renderSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
twentyFourHour,
'Last 24 hours',
);
} else {
blankSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
'Last 24 hours',
);
}
if (hasMonthSource && month) {
renderSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
month,
'This month',
);
} else if (hasMonthSource) {
blankSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
'This month',
);
}
const successCount = [twentyFourHour, month].filter(Boolean).length;
const expectedCount = hasMonthSource ? 2 : 1;
if (successCount === expectedCount) {
setStatus('Live', '#047857');
} else if (successCount === 1) {
setStatus('Partial', '#b45309');
} else {
setStatus('Unavailable', '#b91c1c');
}
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: statusEl.textContent || null,
updated_at: updatedText,
last_24h: twentyFourHour
? {
total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
range_start_local: twentyFourHour.range_start_local || null,
range_end_local: twentyFourHour.range_end_local || null,
}
: null,
this_month: month
? {
total_tokens_processed: Number(month.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
range_start_local: month.range_start_local || null,
range_end_local: month.range_end_local || null,
}
: null,
});
} catch (error) {
const errorText = String(error);
blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
if (hasMonthSource) {
blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
}
updatedEl.textContent = errorText;
setStatus('Unavailable', '#b91c1c');
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
last_24h: null,
this_month: null,
error: errorText,
});
}
};
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -0,0 +1,218 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const valueEl = root.querySelector("[data-co2-value]");
const statusEl = root.querySelector("[data-co2-status]");
const updatedEl = root.querySelector("[data-co2-updated]");
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
const INTERVAL_RAW = Number(state.refresh_ms);
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const setUpdatedNow = () => {
updatedEl.textContent = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const parseValue = (raw) => {
const n = Number(raw);
return Number.isFinite(n) ? Math.round(n) : null;
};
const computeScore = (value) => {
if (!Number.isFinite(value)) return 0;
if (value >= 1600) return 96;
if (value >= 1400) return 90;
if (value >= 1200) return 82;
if (value >= 1000) return 68;
if (value >= 900) return 54;
if (value >= 750) return 34;
return 16;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const refresh = async () => {
setStatus("Refreshing", "var(--theme-status-muted)");
try {
const toolName = await resolveToolName();
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
if (!entry) throw new Error(`Missing sensor ${matchName}`);
const value = parseValue(entry.state);
if (value === null) throw new Error("Invalid sensor payload");
valueEl.textContent = String(value);
if (value >= 1200) setStatus("High", "var(--theme-status-danger)");
else if (value >= 900) setStatus("Elevated", "var(--theme-status-warning)");
else setStatus("Good", "var(--theme-status-live)");
setUpdatedNow();
host.setLiveContent({
kind: 'sensor',
tool_name: toolName,
match_name: entry.name,
value,
display_value: String(value),
unit: entry.attributes?.unit_of_measurement || 'ppm',
status: statusEl.textContent || null,
updated_at: updatedEl.textContent || null,
score: computeScore(value),
});
} catch (err) {
valueEl.textContent = "--";
setStatus("Unavailable", "var(--theme-status-danger)");
updatedEl.textContent = String(err);
host.setLiveContent({
kind: 'sensor',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
match_name: matchName,
value: null,
display_value: '--',
unit: 'ppm',
status: 'Unavailable',
updated_at: String(err),
error: String(err),
score: 0,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, INTERVAL_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,204 +1,15 @@
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 520px;">
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 520px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 12px;">
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Bedroom CO2</h2>
<span data-co2-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Bedroom CO2</h2>
<span data-co2-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="display:flex; align-items:baseline; gap:8px;">
<span data-co2-value style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
<span style="font-size:1rem; font-weight:600; color:#4b5563;">ppm</span>
<span style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-subtle);">ppm</span>
</div>
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
<div style="margin-top:12px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">
Updated: <span data-co2-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const valueEl = root.querySelector("[data-co2-value]");
const statusEl = root.querySelector("[data-co2-status]");
const updatedEl = root.querySelector("[data-co2-updated]");
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
const INTERVAL_RAW = Number(state.refresh_ms);
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const setUpdatedNow = () => {
updatedEl.textContent = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const parseValue = (raw) => {
const n = Number(raw);
return Number.isFinite(n) ? Math.round(n) : null;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const refresh = async () => {
setStatus("Refreshing", "#6b7280");
try {
const toolName = await resolveToolName();
const toolResult = await window.__nanobotCallTool?.(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
if (!entry) throw new Error(`Missing sensor ${matchName}`);
const value = parseValue(entry.state);
if (value === null) throw new Error("Invalid sensor payload");
valueEl.textContent = String(value);
if (value >= 1200) setStatus("High", "#b91c1c");
else if (value >= 900) setStatus("Elevated", "#b45309");
else setStatus("Good", "#047857");
setUpdatedNow();
window.__nanobotSetCardLiveContent?.(script, {
kind: 'sensor',
tool_name: toolName,
match_name: entry.name,
value,
display_value: String(value),
unit: entry.attributes?.unit_of_measurement || 'ppm',
status: statusEl.textContent || null,
updated_at: updatedEl.textContent || null,
});
} catch (err) {
valueEl.textContent = "--";
setStatus("Unavailable", "#b91c1c");
updatedEl.textContent = String(err);
window.__nanobotSetCardLiveContent?.(script, {
kind: 'sensor',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
match_name: matchName,
value: null,
display_value: '--',
unit: 'ppm',
status: 'Unavailable',
updated_at: String(err),
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, INTERVAL_MS);
})();
</script>

View file

@ -0,0 +1,266 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const statusEl = root.querySelector("[data-cal-status]");
const subtitleEl = root.querySelector("[data-cal-subtitle]");
const dateEl = root.querySelector("[data-cal-date]");
const listEl = root.querySelector("[data-cal-list]");
const emptyEl = root.querySelector("[data-cal-empty]");
const updatedEl = root.querySelector("[data-cal-updated]");
if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredCalendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const dayBounds = () => {
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(now);
end.setHours(23, 59, 59, 999);
return { start, end };
};
const formatDateHeader = () => {
const now = new Date();
return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
};
const normalizeDateValue = (value) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
if (typeof value.dateTime === "string") return value.dateTime;
if (typeof value.date === "string") return value.date;
}
return "";
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return "--:--";
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return "--:--";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const isAllDay = (start, end) => {
const s = normalizeDateValue(start);
const e = normalizeDateValue(end);
return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
};
const eventSortKey = (evt) => {
const raw = normalizeDateValue(evt && evt.start);
const t = new Date(raw).getTime();
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!host.listTools) {
return { name: fallbackName, calendars: configuredCalendarNames };
}
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
};
} catch {
return { name: fallbackName, calendars: configuredCalendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = "";
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = "block";
return;
}
emptyEl.style.display = "none";
for (const evt of events) {
const li = document.createElement("li");
li.style.border = "1px solid var(--theme-card-neutral-border)";
li.style.borderRadius = "10px";
li.style.padding = "10px 12px";
li.style.background = "#ffffff";
const summary = document.createElement("div");
summary.style.fontSize = "0.96rem";
summary.style.fontWeight = "600";
summary.style.color = "var(--theme-card-neutral-text)";
summary.textContent = String(evt.summary || "(No title)");
const timing = document.createElement("div");
timing.style.marginTop = "4px";
timing.style.fontSize = "0.86rem";
timing.style.color = "var(--theme-card-neutral-subtle)";
if (isAllDay(evt.start, evt.end)) {
timing.textContent = "All day";
} else {
timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
}
li.appendChild(summary);
li.appendChild(timing);
if (evt.location) {
const location = document.createElement("div");
location.style.marginTop = "4px";
location.style.fontSize = "0.84rem";
location.style.color = "var(--theme-card-neutral-muted)";
location.textContent = `Location: ${String(evt.location)}`;
li.appendChild(location);
}
listEl.appendChild(li);
}
};
const refresh = async () => {
dateEl.textContent = formatDateHeader();
setStatus("Refreshing", "var(--theme-status-muted)");
try {
const toolConfig = await resolveToolConfig();
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
subtitleEl.textContent = "No Home Assistant calendars available";
renderEvents([]);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("OK", "var(--theme-status-live)");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: [],
updated_at: updatedEl.textContent || null,
event_count: 0,
events: [],
});
return;
}
subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
const allEvents = [];
for (const calendarName of toolConfig.calendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const evt of events) {
allEvents.push({ ...evt, _calendarName: calendarName });
}
}
allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
renderEvents(allEvents);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("Daily", "var(--theme-status-live)");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: toolConfig.calendars,
updated_at: updatedEl.textContent || null,
event_count: allEvents.length,
events: allEvents.map((evt) => ({
summary: String(evt.summary || '(No title)'),
start: normalizeDateValue(evt.start) || null,
end: normalizeDateValue(evt.end) || null,
location: typeof evt.location === 'string' ? evt.location : null,
calendar_name: evt._calendarName || null,
})),
});
} catch (err) {
subtitleEl.textContent = "Could not load Home Assistant calendar";
renderEvents([]);
updatedEl.textContent = String(err);
setStatus("Unavailable", "var(--theme-status-danger)");
updateLiveContent({
kind: 'calendar_today',
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: configuredCalendarNames,
updated_at: String(err),
event_count: 0,
events: [],
error: String(err),
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, REFRESH_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,273 +1,23 @@
<div data-calendar-card="today" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 640px;">
<div data-calendar-card="today" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 640px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 12px;">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Today's Calendar</h2>
<div data-cal-subtitle style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Loading calendars...</div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Today's Calendar</h2>
<div data-cal-subtitle style="margin-top:4px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">Loading calendars...</div>
</div>
<span data-cal-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<span data-cal-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="font-size:0.88rem; color:#6b7280; margin-bottom:10px;">
Date: <strong data-cal-date style="color:#374151;">--</strong>
<div style="font-size:0.88rem; color:var(--theme-card-neutral-muted); margin-bottom:10px;">
Date: <strong data-cal-date style="color:var(--theme-card-neutral-subtle);">--</strong>
</div>
<div data-cal-empty style="display:none; padding:12px; border-radius:10px; background:#f8fafc; color:#475569; font-size:0.94rem;">
<div data-cal-empty style="display:none; padding:12px; border-radius:10px; background:color-mix(in srgb, var(--theme-card-neutral-border) 40%, white); color:var(--theme-card-neutral-subtle); font-size:0.94rem;">
No events for today.
</div>
<ul data-cal-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px;"></ul>
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
<div style="margin-top:12px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">
Updated: <span data-cal-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const statusEl = root.querySelector("[data-cal-status]");
const subtitleEl = root.querySelector("[data-cal-subtitle]");
const dateEl = root.querySelector("[data-cal-date]");
const listEl = root.querySelector("[data-cal-list]");
const emptyEl = root.querySelector("[data-cal-empty]");
const updatedEl = root.querySelector("[data-cal-updated]");
if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredCalendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const dayBounds = () => {
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(now);
end.setHours(23, 59, 59, 999);
return { start, end };
};
const formatDateHeader = () => {
const now = new Date();
return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
};
const normalizeDateValue = (value) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
if (typeof value.dateTime === "string") return value.dateTime;
if (typeof value.date === "string") return value.date;
}
return "";
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return "--:--";
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return "--:--";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const isAllDay = (start, end) => {
const s = normalizeDateValue(start);
const e = normalizeDateValue(end);
return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
};
const eventSortKey = (evt) => {
const raw = normalizeDateValue(evt && evt.start);
const t = new Date(raw).getTime();
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!window.__nanobotListTools) {
return { name: fallbackName, calendars: configuredCalendarNames };
}
try {
const tools = await window.__nanobotListTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
};
} catch {
return { name: fallbackName, calendars: configuredCalendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = "";
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = "block";
return;
}
emptyEl.style.display = "none";
for (const evt of events) {
const li = document.createElement("li");
li.style.border = "1px solid #e5e7eb";
li.style.borderRadius = "10px";
li.style.padding = "10px 12px";
li.style.background = "#ffffff";
const summary = document.createElement("div");
summary.style.fontSize = "0.96rem";
summary.style.fontWeight = "600";
summary.style.color = "#111827";
summary.textContent = String(evt.summary || "(No title)");
const timing = document.createElement("div");
timing.style.marginTop = "4px";
timing.style.fontSize = "0.86rem";
timing.style.color = "#475569";
if (isAllDay(evt.start, evt.end)) {
timing.textContent = "All day";
} else {
timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
}
li.appendChild(summary);
li.appendChild(timing);
if (evt.location) {
const location = document.createElement("div");
location.style.marginTop = "4px";
location.style.fontSize = "0.84rem";
location.style.color = "#6b7280";
location.textContent = `Location: ${String(evt.location)}`;
li.appendChild(location);
}
listEl.appendChild(li);
}
};
const refresh = async () => {
dateEl.textContent = formatDateHeader();
setStatus("Refreshing", "#6b7280");
try {
const toolConfig = await resolveToolConfig();
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
subtitleEl.textContent = "No Home Assistant calendars available";
renderEvents([]);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("OK", "#047857");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: [],
updated_at: updatedEl.textContent || null,
event_count: 0,
events: [],
});
return;
}
subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
const allEvents = [];
for (const calendarName of toolConfig.calendars) {
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const evt of events) {
allEvents.push({ ...evt, _calendarName: calendarName });
}
}
allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
renderEvents(allEvents);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("Daily", "#047857");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: toolConfig.calendars,
updated_at: updatedEl.textContent || null,
event_count: allEvents.length,
events: allEvents.map((evt) => ({
summary: String(evt.summary || '(No title)'),
start: normalizeDateValue(evt.start) || null,
end: normalizeDateValue(evt.end) || null,
location: typeof evt.location === 'string' ? evt.location : null,
calendar_name: evt._calendarName || null,
})),
});
} catch (err) {
subtitleEl.textContent = "Could not load Home Assistant calendar";
renderEvents([]);
updatedEl.textContent = String(err);
setStatus("Unavailable", "#b91c1c");
updateLiveContent({
kind: 'calendar_today',
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: configuredCalendarNames,
updated_at: String(err),
event_count: 0,
events: [],
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, REFRESH_MS);
})();
</script>

View file

@ -0,0 +1,243 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const tempEl = root.querySelector("[data-weather-temp]");
const unitEl = root.querySelector("[data-weather-unit]");
const condEl = root.querySelector("[data-weather-condition]");
const humidityEl = root.querySelector("[data-weather-humidity]");
const windEl = root.querySelector("[data-weather-wind]");
const pressureEl = root.querySelector("[data-weather-pressure]");
const updatedEl = root.querySelector("[data-weather-updated]");
const statusEl = root.querySelector("[data-weather-status]");
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const nowLabel = () => new Date().toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const refresh = async () => {
setStatus("Refreshing", "var(--theme-status-muted)");
try {
const toolName = await resolveToolName();
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [temperatureName]);
const humidityEntry = findEntry(entries, [humidityName]);
const pressureEntry = findEntry(entries, [pressureName]);
const windEntry = findEntry(entries, [windName]);
const tempNum = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
condEl.textContent = conditionLabel;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
const pressureNum = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressureNum)
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
: "--";
updatedEl.textContent = nowLabel();
setStatus("Live", "var(--theme-status-live)");
host.setLiveContent({
kind: 'weather',
tool_name: toolName,
provider_prefix: providerPrefix,
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
condition: condEl.textContent || null,
updated_at: updatedEl.textContent || null,
status: statusEl.textContent || null,
});
} catch (err) {
setStatus("Unavailable", "var(--theme-status-danger)");
updatedEl.textContent = String(err);
host.setLiveContent({
kind: 'weather',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
provider_prefix: providerPrefix,
temperature: null,
humidity: null,
wind: null,
pressure: null,
condition: null,
updated_at: String(err),
status: 'Unavailable',
error: String(err),
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, REFRESH_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,250 +1,23 @@
<div data-weather-card="01545" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 560px;">
<div data-weather-card="01545" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 560px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 10px;">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Weather 01545</h2>
<div style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Source: Home Assistant (weather.openweathermap)</div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Weather 01545</h2>
<div style="margin-top:4px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">Source: Home Assistant (weather.openweathermap)</div>
</div>
<span data-weather-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<span data-weather-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="display:flex; align-items:baseline; gap:10px; margin-bottom:10px;">
<span data-weather-temp style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
<span data-weather-unit style="font-size:1rem; font-weight:600; color:#4b5563;">°F</span>
<span data-weather-unit style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-subtle);">°F</span>
</div>
<div data-weather-condition style="font-size:1rem; font-weight:600; color:#1f2937; margin-bottom:8px; text-transform:capitalize;">--</div>
<div data-weather-condition style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-text); margin-bottom:8px; text-transform:capitalize;">--</div>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:8px; font-size:0.9rem; color:#374151;">
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:8px; font-size:0.9rem; color:var(--theme-card-neutral-subtle);">
<div>Humidity: <strong data-weather-humidity>--</strong></div>
<div>Wind: <strong data-weather-wind>--</strong></div>
<div>Pressure: <strong data-weather-pressure>--</strong></div>
<div>Updated: <strong data-weather-updated>--</strong></div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const tempEl = root.querySelector("[data-weather-temp]");
const unitEl = root.querySelector("[data-weather-unit]");
const condEl = root.querySelector("[data-weather-condition]");
const humidityEl = root.querySelector("[data-weather-humidity]");
const windEl = root.querySelector("[data-weather-wind]");
const pressureEl = root.querySelector("[data-weather-pressure]");
const updatedEl = root.querySelector("[data-weather-updated]");
const statusEl = root.querySelector("[data-weather-status]");
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const nowLabel = () => new Date().toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const refresh = async () => {
setStatus("Refreshing", "#6b7280");
try {
const toolName = await resolveToolName();
const toolResult = await window.__nanobotCallTool?.(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [temperatureName]);
const humidityEntry = findEntry(entries, [humidityName]);
const pressureEntry = findEntry(entries, [pressureName]);
const windEntry = findEntry(entries, [windName]);
const tempNum = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
condEl.textContent = conditionLabel;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
const pressureNum = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressureNum)
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
: "--";
updatedEl.textContent = nowLabel();
setStatus("Live", "#047857");
window.__nanobotSetCardLiveContent?.(script, {
kind: 'weather',
tool_name: toolName,
provider_prefix: providerPrefix,
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
condition: condEl.textContent || null,
updated_at: updatedEl.textContent || null,
status: statusEl.textContent || null,
});
} catch (err) {
setStatus("Unavailable", "#b91c1c");
updatedEl.textContent = String(err);
window.__nanobotSetCardLiveContent?.(script, {
kind: 'weather',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
provider_prefix: providerPrefix,
temperature: null,
humidity: null,
wind: null,
pressure: null,
condition: null,
updated_at: String(err),
status: 'Unavailable',
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, REFRESH_MS);
})();
</script>

View file

@ -0,0 +1,309 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-sensor-subtitle]');
const valueEl = root.querySelector('[data-sensor-value]');
const unitEl = root.querySelector('[data-sensor-unit]');
const statusEl = root.querySelector('[data-sensor-status]');
const updatedEl = root.querySelector('[data-sensor-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
const matchNames = Array.isArray(state.match_names)
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const searchTerms = Array.isArray(state.search_terms)
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
const decimalsRaw = Number(state.value_decimals);
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
const goodMax = Number(thresholds.good_max);
const elevatedMax = Number(thresholds.elevated_max);
const alertOnly = state.alert_only === true;
const elevatedScoreRaw = Number(state.alert_score_elevated);
const highScoreRaw = Number(state.alert_score_high);
const elevatedScore = Number.isFinite(elevatedScoreRaw) ? elevatedScoreRaw : 88;
const highScore = Number.isFinite(highScoreRaw) ? highScoreRaw : 98;
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
unitEl.textContent = fallbackUnit || '--';
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const renderValue = (value) => {
if (!Number.isFinite(value)) return '--';
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const allMatchNames = [matchName, ...matchNames].filter(Boolean);
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
const scoreEntry = (entry) => {
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
const entryName = normalizeText(entry.name);
let score = 0;
for (const candidate of allMatchNames) {
const normalized = normalizeText(candidate);
if (!normalized) continue;
if (entryName === normalized) score += 100;
else if (entryName.includes(normalized)) score += 40;
}
for (const term of allSearchTerms) {
const normalized = normalizeText(term);
if (!normalized) continue;
if (entryName.includes(normalized)) score += 10;
}
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
return score;
};
const findSensorEntry = (entries) => {
const scored = entries
.map((entry) => ({ entry, score: scoreEntry(entry) }))
.filter((item) => Number.isFinite(item.score) && item.score > 0)
.sort((left, right) => right.score - left.score);
return scored.length > 0 ? scored[0].entry : null;
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const classify = (value) => {
if (!Number.isFinite(value)) return { label: 'Unavailable', color: 'var(--theme-status-danger)' };
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: 'var(--theme-status-danger)' };
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: 'var(--theme-status-warning)' };
return { label: 'Good', color: 'var(--theme-status-live)' };
};
const computeAlertVisibility = (statusLabel) => {
if (!alertOnly) return false;
return statusLabel === 'Good' || statusLabel === 'Unavailable';
};
const computeAlertScore = (statusLabel, value) => {
if (!alertOnly) return Number.isFinite(value) ? 0 : null;
if (statusLabel === 'High') return highScore;
if (statusLabel === 'Elevated') return elevatedScore;
return 0;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
valueEl.textContent = '--';
setStatus('No tool', 'var(--theme-status-danger)');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: null,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
}
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const toolResult = await host.callTool(resolvedToolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = findSensorEntry(entries);
if (!entry) throw new Error('Matching sensor not found in live context');
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
const numericValue = Number(entry.state);
const renderedValue = renderValue(numericValue);
valueEl.textContent = renderedValue;
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
unitEl.textContent = unit;
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
const status = classify(numericValue);
setStatus(status.label, status.color);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: entry.name || matchName || matchNames[0] || null,
value: Number.isFinite(numericValue) ? numericValue : null,
display_value: renderedValue,
unit,
status: status.label,
updated_at: updatedText,
score: computeAlertScore(status.label, numericValue),
hidden: computeAlertVisibility(status.label),
});
} catch (error) {
const errorText = String(error);
valueEl.textContent = '--';
setStatus('Unavailable', 'var(--theme-status-danger)');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
score: alertOnly ? 0 : null,
hidden: alertOnly,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,7 +1,7 @@
{
"key": "sensor-live",
"title": "Live Sensor",
"notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. The card title comes from the feed header, not the template body.",
"notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. Set alert_only=true to hide the card while readings are good and surface it only when elevated/high, with optional alert_score_elevated/alert_score_high overrides. The card title comes from the feed header, not the template body.",
"example_state": {
"subtitle": "Home Assistant sensor",
"tool_name": "mcp_home_assistant_GetLiveContext",
@ -13,7 +13,8 @@
"thresholds": {
"good_max": 900,
"elevated_max": 1200
}
},
"alert_only": false
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"

View file

@ -1,287 +1,15 @@
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div style="display:flex; align-items:flex-end; gap:8px;">
<span data-sensor-value style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">--</span>
</div>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">
Updated <span data-sensor-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-sensor-subtitle]');
const valueEl = root.querySelector('[data-sensor-value]');
const unitEl = root.querySelector('[data-sensor-unit]');
const statusEl = root.querySelector('[data-sensor-status]');
const updatedEl = root.querySelector('[data-sensor-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
const matchNames = Array.isArray(state.match_names)
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const searchTerms = Array.isArray(state.search_terms)
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
const decimalsRaw = Number(state.value_decimals);
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
const goodMax = Number(thresholds.good_max);
const elevatedMax = Number(thresholds.elevated_max);
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
unitEl.textContent = fallbackUnit || '--';
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const renderValue = (value) => {
if (!Number.isFinite(value)) return '--';
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const allMatchNames = [matchName, ...matchNames].filter(Boolean);
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
const scoreEntry = (entry) => {
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
const entryName = normalizeText(entry.name);
let score = 0;
for (const candidate of allMatchNames) {
const normalized = normalizeText(candidate);
if (!normalized) continue;
if (entryName === normalized) score += 100;
else if (entryName.includes(normalized)) score += 40;
}
for (const term of allSearchTerms) {
const normalized = normalizeText(term);
if (!normalized) continue;
if (entryName.includes(normalized)) score += 10;
}
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
return score;
};
const findSensorEntry = (entries) => {
const scored = entries
.map((entry) => ({ entry, score: scoreEntry(entry) }))
.filter((item) => Number.isFinite(item.score) && item.score > 0)
.sort((left, right) => right.score - left.score);
return scored.length > 0 ? scored[0].entry : null;
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const classify = (value) => {
if (!Number.isFinite(value)) return { label: 'Unavailable', color: '#b91c1c' };
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: '#b91c1c' };
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: '#b45309' };
return { label: 'Good', color: '#047857' };
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
valueEl.textContent = '--';
setStatus('No tool', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: null,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
}
setStatus('Refreshing', '#6b7280');
try {
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = findSensorEntry(entries);
if (!entry) throw new Error('Matching sensor not found in live context');
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
const numericValue = Number(entry.state);
const renderedValue = renderValue(numericValue);
valueEl.textContent = renderedValue;
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
unitEl.textContent = unit;
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
const status = classify(numericValue);
setStatus(status.label, status.color);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: entry.name || matchName || matchNames[0] || null,
value: Number.isFinite(numericValue) ? numericValue : null,
display_value: renderedValue,
unit,
status: status.label,
updated_at: updatedText,
});
} catch (error) {
const errorText = String(error);
valueEl.textContent = '--';
setStatus('Unavailable', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -0,0 +1,494 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const dateEl = root.querySelector('[data-today-date]');
const tempEl = root.querySelector('[data-today-temp]');
const unitEl = root.querySelector('[data-today-unit]');
const feelsLikeEl = root.querySelector('[data-today-feels-like]');
const aqiChipEl = root.querySelector('[data-today-aqi-chip]');
const countEl = root.querySelector('[data-today-events-count]');
const emptyEl = root.querySelector('[data-today-empty]');
const listEl = root.querySelector('[data-today-events-list]');
const updatedEl = root.querySelector('[data-today-updated]');
if (!(dateEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(feelsLikeEl instanceof HTMLElement) || !(aqiChipEl instanceof HTMLElement) || !(countEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : '';
const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const weatherPrefix = typeof state.weather_prefix === 'string' ? state.weather_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const apparentTemperatureName = typeof state.apparent_temperature_name === 'string' ? state.apparent_temperature_name.trim() : '';
const aqiName = typeof state.aqi_name === 'string' ? state.aqi_name.trim() : '';
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 6;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'Nothing scheduled today.';
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const formatHeaderDate = (value) => value.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatEventDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short' });
};
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const findByDeviceClass = (entries, deviceClass) => entries.find((entry) => normalizeText(entry.attributes?.device_class) === normalizeText(deviceClass)) || null;
const parseNumericValue = (entry) => {
const value = Number(entry?.state);
return Number.isFinite(value) ? value : null;
};
const formatMetric = (value, unit) => {
if (!Number.isFinite(value)) return '--';
return `${Math.round(value)}${unit ? ` ${unit}` : ''}`;
};
const buildAqiStyle = (aqiValue) => {
if (!Number.isFinite(aqiValue)) {
return { label: 'AQI --', tone: 'Unavailable', background: 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)', color: 'var(--theme-card-neutral-subtle)' };
}
if (aqiValue <= 50) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Good', background: 'color-mix(in srgb, var(--theme-status-live) 16%, white)', color: 'var(--theme-status-live)' };
if (aqiValue <= 100) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Moderate', background: 'color-mix(in srgb, var(--theme-status-warning) 16%, white)', color: 'var(--theme-status-warning)' };
if (aqiValue <= 150) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Sensitive', background: 'color-mix(in srgb, var(--theme-status-warning) 24%, white)', color: 'var(--theme-status-warning)' };
if (aqiValue <= 200) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Unhealthy', background: 'color-mix(in srgb, var(--theme-status-danger) 14%, white)', color: 'var(--theme-status-danger)' };
if (aqiValue <= 300) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Very unhealthy', background: 'color-mix(in srgb, var(--theme-accent) 14%, white)', color: 'var(--theme-accent-strong)' };
return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Hazardous', background: 'color-mix(in srgb, var(--theme-accent-strong) 18%, white)', color: 'var(--theme-accent-strong)' };
};
const startTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.NaN;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const computeTodayScore = (events, status) => {
if (status === 'Unavailable') return 5;
if (!Array.isArray(events) || events.length === 0) {
return status === 'Partial' ? 18 : 24;
}
const now = Date.now();
const soonestMs = events
.map((event) => startTime(event?.start))
.filter((time) => Number.isFinite(time) && time >= now)
.sort((left, right) => left - right)[0];
const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
let score = 34;
if (soonestHours !== null) {
if (soonestHours <= 1) score = 80;
else if (soonestHours <= 3) score = 70;
else if (soonestHours <= 8) score = 58;
else if (soonestHours <= 24) score = 46;
}
score += Math.min(events.length, 3) * 3;
if (status === 'Partial') score -= 8;
return Math.max(0, Math.min(100, Math.round(score)));
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
countEl.textContent = 'No events';
return;
}
emptyEl.style.display = 'none';
countEl.textContent = `${events.length} ${events.length === 1 ? 'event' : 'events'}`;
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 0' : '10px 0 0';
item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const timing = document.createElement('div');
timing.style.fontSize = '0.76rem';
timing.style.lineHeight = '1.2';
timing.style.textTransform = 'uppercase';
timing.style.letterSpacing = '0.05em';
timing.style.color = 'var(--theme-card-neutral-muted)';
timing.style.fontWeight = '700';
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatEventDay(event.start)} · ${formatTime(event.start)}`;
timing.textContent = timeLabel;
item.appendChild(timing);
const summary = document.createElement('div');
summary.style.marginTop = '4px';
summary.style.fontSize = '0.95rem';
summary.style.lineHeight = '1.35';
summary.style.color = 'var(--theme-card-neutral-text)';
summary.style.fontWeight = '700';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
listEl.appendChild(item);
}
};
const resolveToolName = async (configuredName, pattern, fallbackName) => {
if (configuredName) return configuredName;
if (!host.listTools) return fallbackName;
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => pattern.test(String(item?.name || '')))
: null;
return tool?.name || fallbackName;
} catch {
return fallbackName;
}
};
const resolveCalendarNames = async (toolName) => {
if (calendarNames.length > 0) return calendarNames;
if (!host.listTools) return calendarNames;
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => String(item?.name || '') === toolName)
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return enumValues;
} catch {
return calendarNames;
}
};
const loadWeather = async (toolName) => {
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [
temperatureName,
`${weatherPrefix} Temperature`,
]) || findByDeviceClass(entries, 'temperature');
const apparentEntry = findEntry(entries, [
apparentTemperatureName,
`${weatherPrefix} Apparent temperature`,
`${weatherPrefix} Feels like`,
]);
const aqiEntry = findEntry(entries, [
aqiName,
'Air quality index',
]) || findByDeviceClass(entries, 'aqi');
return {
toolName,
temperature: parseNumericValue(temperatureEntry),
temperatureUnit: String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
feelsLike: parseNumericValue(apparentEntry),
feelsLikeUnit: String(apparentEntry?.attributes?.unit_of_measurement || temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
aqi: parseNumericValue(aqiEntry),
};
};
const loadEvents = async (toolName) => {
const selectedCalendars = await resolveCalendarNames(toolName);
if (!toolName) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
const endExclusiveTime = end.getTime() + 1;
const allEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolName, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventTime(left?.start) - eventTime(right?.start));
return {
toolName,
calendarNames: selectedCalendars,
events: allEvents.slice(0, maxEvents),
};
};
const refresh = async () => {
const now = new Date();
dateEl.textContent = formatHeaderDate(now);
const [weatherToolName, calendarToolName] = await Promise.all([
resolveToolName(configuredWeatherToolName, /(^|_)GetLiveContext$/i, 'mcp_home_assistant_GetLiveContext'),
resolveToolName(configuredCalendarToolName, /(^|_)calendar_get_events$/i, 'mcp_home_assistant_calendar_get_events'),
]);
const [weatherResult, eventsResult] = await Promise.allSettled([
loadWeather(weatherToolName),
loadEvents(calendarToolName),
]);
const snapshot = {
kind: 'today_briefing',
date_label: dateEl.textContent || null,
weather_tool_name: weatherToolName || null,
calendar_tool_name: calendarToolName || null,
updated_at: null,
status: null,
weather: null,
events: [],
errors: {},
};
let successCount = 0;
if (weatherResult.status === 'fulfilled') {
successCount += 1;
const weather = weatherResult.value;
tempEl.textContent = Number.isFinite(weather.temperature) ? String(Math.round(weather.temperature)) : '--';
unitEl.textContent = weather.temperatureUnit || '°F';
feelsLikeEl.textContent = formatMetric(weather.feelsLike, weather.feelsLikeUnit);
const aqiStyle = buildAqiStyle(weather.aqi);
aqiChipEl.textContent = `${aqiStyle.tone} · ${aqiStyle.label}`;
aqiChipEl.style.background = aqiStyle.background;
aqiChipEl.style.color = aqiStyle.color;
snapshot.weather = {
temperature: Number.isFinite(weather.temperature) ? Math.round(weather.temperature) : null,
temperature_unit: weather.temperatureUnit || null,
feels_like: Number.isFinite(weather.feelsLike) ? Math.round(weather.feelsLike) : null,
aqi: Number.isFinite(weather.aqi) ? Math.round(weather.aqi) : null,
aqi_tone: aqiStyle.tone,
};
} else {
tempEl.textContent = '--';
unitEl.textContent = '°F';
feelsLikeEl.textContent = '--';
aqiChipEl.textContent = 'AQI unavailable';
aqiChipEl.style.background = 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)';
aqiChipEl.style.color = 'var(--theme-card-neutral-subtle)';
snapshot.errors.weather = String(weatherResult.reason);
}
if (eventsResult.status === 'fulfilled') {
successCount += 1;
const eventsData = eventsResult.value;
renderEvents(eventsData.events);
snapshot.events = eventsData.events.map((event) => ({
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
all_day: isAllDay(event.start, event.end),
}));
} else {
renderEvents([]);
countEl.textContent = 'Unavailable';
emptyEl.style.display = 'block';
emptyEl.textContent = 'Calendar unavailable.';
snapshot.errors.events = String(eventsResult.reason);
}
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
snapshot.updated_at = updatedText;
if (successCount === 2) {
snapshot.status = 'Ready';
} else if (successCount === 1) {
snapshot.status = 'Partial';
} else {
snapshot.status = 'Unavailable';
}
snapshot.score = computeTodayScore(snapshot.events, snapshot.status);
updateLiveContent(snapshot);
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -0,0 +1,21 @@
{
"key": "today-briefing-live",
"title": "Today Briefing",
"notes": "Single-card day overview for local use. Fill template_state with weather_tool_name (defaults to Home Assistant GetLiveContext), calendar_tool_name (defaults to calendar_get_events), calendar_names, weather_prefix or exact sensor names for temperature/apparent/AQI, max_events, refresh_ms, and empty_text.",
"example_state": {
"weather_tool_name": "mcp_home_assistant_GetLiveContext",
"calendar_tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"weather_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"apparent_temperature_name": "OpenWeatherMap Apparent temperature",
"aqi_name": "Worcester Summer St Air quality index",
"max_events": 6,
"refresh_ms": 900000,
"empty_text": "Nothing scheduled today."
},
"created_at": "2026-03-15T22:35:00+00:00",
"updated_at": "2026-03-15T22:35:00+00:00"
}

View file

@ -0,0 +1,32 @@
<div data-today-briefing-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px; border:1px solid var(--theme-card-neutral-border);">
<div data-today-date style="font-size:0.86rem; line-height:1.2; letter-spacing:0.02em; color:var(--theme-card-neutral-muted); font-weight:700; margin-bottom:10px; white-space:nowrap;">Today</div>
<div>
<div style="font-size:0.76rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.05em; color:var(--theme-card-neutral-muted); font-weight:700;">Weather</div>
<div style="display:flex; align-items:flex-end; gap:8px; margin-top:10px;">
<span data-today-temp style="font-size:3rem; line-height:0.92; letter-spacing:-0.05em; font-weight:800;">--</span>
<span data-today-unit style="font-size:1.05rem; line-height:1.1; color:var(--theme-card-neutral-subtle); font-weight:700; padding-bottom:0.32rem;">°F</span>
</div>
<div style="display:flex; flex-wrap:wrap; align-items:baseline; gap:8px; margin-top:8px; font-size:0.92rem; line-height:1.3;">
<span style="color:var(--theme-card-neutral-muted); font-weight:700;">Feels like</span>
<span data-today-feels-like style="color:var(--theme-card-neutral-text); font-weight:800;">--</span>
</div>
<div style="margin-top:8px;">
<span data-today-aqi-chip style="display:inline-flex; padding:4px 8px; background:color-mix(in srgb, var(--theme-card-neutral-border) 65%, white); color:var(--theme-card-neutral-subtle); font-size:0.78rem; line-height:1.1; font-weight:800; text-transform:uppercase; letter-spacing:0.05em;">AQI --</span>
</div>
</div>
<div style="margin-top:14px; padding-top:12px; border-top:1px solid color-mix(in srgb, var(--theme-card-neutral-border) 82%, transparent);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px;">
<div style="font-size:0.76rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.05em; color:var(--theme-card-neutral-muted); font-weight:700;">Agenda</div>
<div data-today-events-count style="font-size:0.82rem; line-height:1.2; color:var(--theme-card-neutral-subtle); font-weight:700;">--</div>
</div>
<div data-today-empty style="display:none; margin-top:8px; color:var(--theme-card-neutral-subtle); font-size:0.96rem; line-height:1.4;">Nothing scheduled today.</div>
<ul data-today-events-list style="list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
</div>
<div style="margin-top:10px; font-size:0.8rem; line-height:1.3; color:var(--theme-card-neutral-muted);">Updated <span data-today-updated>--</span></div>
</div>

View file

@ -0,0 +1,708 @@
const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"];
const TASK_ACTION_LABELS = {
backlog: "Backlog",
committed: "Commit",
"in-progress": "Start",
blocked: "Block",
done: "Done",
canceled: "Cancel",
};
const TASK_LANE_LABELS = {
backlog: "Backlog",
committed: "Committed",
"in-progress": "In Progress",
blocked: "Blocked",
done: "Done",
canceled: "Canceled",
};
const TASK_LANE_THEMES = {
backlog: {
accent: "#5f7884",
accentSoft: "rgba(95, 120, 132, 0.13)",
muted: "#6b7e87",
buttonInk: "#294a57",
},
committed: {
accent: "#8a6946",
accentSoft: "rgba(138, 105, 70, 0.14)",
muted: "#7f664a",
buttonInk: "#5a3b19",
},
"in-progress": {
accent: "#4f7862",
accentSoft: "rgba(79, 120, 98, 0.13)",
muted: "#5e7768",
buttonInk: "#214437",
},
blocked: {
accent: "#a55f4b",
accentSoft: "rgba(165, 95, 75, 0.13)",
muted: "#906659",
buttonInk: "#6c2f21",
},
done: {
accent: "#6d7f58",
accentSoft: "rgba(109, 127, 88, 0.12)",
muted: "#6b755d",
buttonInk: "#304121",
},
canceled: {
accent: "#7b716a",
accentSoft: "rgba(123, 113, 106, 0.12)",
muted: "#7b716a",
buttonInk: "#433932",
},
};
function isTaskLane(value) {
return typeof value === "string" && TASK_LANES.includes(value);
}
function normalizeTag(raw) {
const trimmed = String(raw || "")
.trim()
.replace(/^#+/, "")
.replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTags(raw) {
if (!Array.isArray(raw)) return [];
const seen = new Set();
const tags = [];
for (const value of raw) {
const tag = normalizeTag(value);
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeMetadata(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const entries = Object.entries(raw).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries);
}
function normalizeTask(raw, fallbackTitle) {
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const lane = isTaskLane(record.lane) ? record.lane : "backlog";
return {
taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "",
taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "",
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: fallbackTitle || "(Untitled task)",
lane,
created: typeof record.created === "string" ? record.created.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
due: typeof record.due === "string" ? record.due.trim() : "",
tags: normalizeTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
metadata: normalizeMetadata(record.metadata),
};
}
function normalizeTaskFromPayload(raw, fallback) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
return {
taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath,
taskKey: fallback.taskKey,
title:
typeof raw.title === "string" && raw.title.trim()
? raw.title.trim()
: fallback.title,
lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane,
created: typeof raw.created === "string" ? raw.created.trim() : fallback.created,
updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated,
due: typeof raw.due === "string" ? raw.due.trim() : fallback.due,
tags: normalizeTags(raw.tags),
body: typeof raw.body === "string" ? raw.body : fallback.body,
metadata: normalizeMetadata(raw.metadata),
};
}
function parseToolPayload(result) {
if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") {
return result.parsed;
}
const raw = typeof result?.content === "string" ? result.content : "";
if (!raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function dueScore(hoursUntilDue) {
if (hoursUntilDue <= 0) return 100;
if (hoursUntilDue <= 6) return 96;
if (hoursUntilDue <= 24) return 92;
if (hoursUntilDue <= 72) return 82;
if (hoursUntilDue <= 168) return 72;
return 62;
}
function ageScore(ageDays) {
if (ageDays >= 30) return 80;
if (ageDays >= 21) return 76;
if (ageDays >= 14) return 72;
if (ageDays >= 7) return 68;
if (ageDays >= 3) return 62;
if (ageDays >= 1) return 58;
return 54;
}
function computeTaskScore(task) {
const now = Date.now();
const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
let score = 54;
if (Number.isFinite(dueMs)) {
score = dueScore((dueMs - now) / (60 * 60 * 1000));
} else {
const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
if (Number.isFinite(createdMs)) {
score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)));
}
}
if (task.lane === "committed") return Math.min(100, score + 1);
if (task.lane === "blocked") return Math.min(100, score + 4);
if (task.lane === "in-progress") return Math.min(100, score + 2);
return score;
}
function summarizeTaskBody(task) {
const trimmed = String(task.body || "").trim();
if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
return trimmed;
}
function renderTaskBodyMarkdown(host, body) {
if (!body) return "";
return body
.replace(/\r\n?/g, "\n")
.trim()
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '<span class="task-card-ui__md-break" aria-hidden="true"></span>';
let className = "task-card-ui__md-line";
let content = trimmed;
let prefix = "";
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
className += " task-card-ui__md-line--heading";
content = headingMatch[2];
} else if (/^[-*]\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^[-*]\s+/, "");
prefix = "\u2022 ";
} else if (/^\d+\.\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^\d+\.\s+/, "");
prefix = "\u2022 ";
} else if (/^>\s+/.test(trimmed)) {
className += " task-card-ui__md-line--quote";
content = trimmed.replace(/^>\s+/, "");
prefix = "> ";
}
const html = host.renderMarkdown(content, { inline: true });
return `<span class="${className}">${
prefix ? `<span class="task-card-ui__md-prefix">${prefix}</span>` : ""
}${html}</span>`;
})
.join("");
}
function formatTaskDue(task) {
if (!task.due) return "";
const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return task.due;
if (task.due.includes("T")) {
const label = parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return label.replace(/\s([AP]M)$/i, "$1");
}
return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
}
function taskMoveOptions(lane) {
return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
lane: targetLane,
label: TASK_ACTION_LABELS[targetLane],
}));
}
function taskLiveContent(task, errorText) {
return {
kind: "file_task",
exists: true,
task_path: task.taskPath || null,
task_key: task.taskKey || null,
title: task.title || null,
lane: task.lane,
created: task.created || null,
updated: task.updated || null,
due: task.due || null,
tags: task.tags,
metadata: task.metadata,
score: computeTaskScore(task),
status: task.lane,
error: errorText || null,
};
}
function autosizeEditor(editor) {
editor.style.height = "0px";
editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
}
export function mount({ root, item, state, host }) {
const cardEl = root.querySelector(".task-card-ui");
const laneToggleEl = root.querySelector(".task-card-ui__lane-button");
const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap");
const laneMenuEl = root.querySelector(".task-card-ui__lane-menu");
const statusEl = root.querySelector(".task-card-ui__status");
const titleEl = root.querySelector(".task-card-ui__title-slot");
const tagsEl = root.querySelector(".task-card-ui__tags");
const bodyEl = root.querySelector(".task-card-ui__body-slot");
const metaEl = root.querySelector(".task-card-ui__meta");
const dueEl = root.querySelector(".task-card-ui__chip");
if (
!(cardEl instanceof HTMLElement) ||
!(laneToggleEl instanceof HTMLButtonElement) ||
!(laneWrapEl instanceof HTMLElement) ||
!(laneMenuEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(titleEl instanceof HTMLElement) ||
!(tagsEl instanceof HTMLElement) ||
!(bodyEl instanceof HTMLElement) ||
!(metaEl instanceof HTMLElement) ||
!(dueEl instanceof HTMLElement)
) {
return;
}
let task = normalizeTask(state, item.title);
let busy = false;
let errorText = "";
let statusLabel = "";
let statusKind = "neutral";
let laneMenuOpen = false;
let editingField = null;
let holdTimer = null;
const clearHoldTimer = () => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
};
const setStatus = (label, kind) => {
statusLabel = label || "";
statusKind = kind || "neutral";
statusEl.textContent = statusLabel;
statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`;
};
const publishLiveContent = () => {
host.setLiveContent(taskLiveContent(task, errorText));
};
const closeLaneMenu = () => {
laneMenuOpen = false;
laneToggleEl.setAttribute("aria-expanded", "false");
laneMenuEl.style.display = "none";
};
const openLaneMenu = () => {
if (busy || !task.taskPath) return;
laneMenuOpen = true;
laneToggleEl.setAttribute("aria-expanded", "true");
laneMenuEl.style.display = "flex";
};
const refreshFeed = () => {
closeLaneMenu();
host.requestFeedRefresh();
};
const setBusy = (nextBusy) => {
busy = !!nextBusy;
laneToggleEl.disabled = busy || !task.taskPath;
titleEl.style.pointerEvents = busy ? "none" : "";
bodyEl.style.pointerEvents = busy ? "none" : "";
tagsEl
.querySelectorAll("button")
.forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop")));
laneMenuEl.querySelectorAll("button").forEach((button) => {
button.disabled = busy;
});
};
const runBusyAction = async (action) => {
setBusy(true);
errorText = "";
setStatus("Saving", "neutral");
try {
await action();
setStatus("", "neutral");
} catch (error) {
console.error("Task card action failed", error);
errorText = error instanceof Error ? error.message : String(error);
setStatus("Unavailable", "error");
} finally {
setBusy(false);
render();
}
};
const callTaskBoard = async (argumentsValue) => {
const result = await host.callTool("task_board", argumentsValue);
const payload = parseToolPayload(result);
if (payload && typeof payload.error === "string" && payload.error.trim()) {
throw new Error(payload.error);
}
return payload;
};
const moveTask = async (lane) =>
runBusyAction(async () => {
const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane });
const nextTask = normalizeTaskFromPayload(payload?.task, {
...task,
lane,
taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath,
updated: new Date().toISOString(),
});
task = nextTask;
refreshFeed();
});
const editField = async (field, rawValue) => {
const nextValue = rawValue.trim();
const currentValue = field === "title" ? task.title : task.body;
if (field === "title" && !nextValue) return false;
if (nextValue === currentValue) {
editingField = null;
render();
return true;
}
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "edit",
task: task.taskPath,
...(field === "title" ? { title: nextValue } : { description: nextValue }),
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
...(field === "title" ? { title: nextValue } : { body: nextValue }),
});
editingField = null;
refreshFeed();
});
return true;
};
const addTag = async () => {
const raw = window.prompt("Add tag to task", "");
const tag = raw == null ? "" : normalizeTag(raw);
if (!tag) return;
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "add_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: Array.from(new Set([...task.tags, tag])),
});
refreshFeed();
});
};
const removeTag = async (tag) =>
runBusyAction(async () => {
const payload = await callTaskBoard({
action: "remove_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: task.tags.filter((value) => value !== tag),
});
refreshFeed();
});
const beginTitleEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "title";
render();
};
const beginBodyEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "body";
render();
};
const renderInlineEditor = (targetEl, field, value, placeholder) => {
targetEl.innerHTML = "";
const editor = document.createElement("textarea");
editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`;
editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length);
editor.value = value;
if (placeholder) editor.placeholder = placeholder;
editor.disabled = busy;
targetEl.appendChild(editor);
autosizeEditor(editor);
const cancel = () => {
editingField = null;
render();
};
editor.addEventListener("input", () => {
autosizeEditor(editor);
});
editor.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
cancel();
return;
}
if (field === "title" && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
editor.blur();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
editor.blur();
}
});
editor.addEventListener("blur", () => {
if (editingField !== field) return;
void editField(field, editor.value);
});
requestAnimationFrame(() => {
editor.focus();
const end = editor.value.length;
editor.setSelectionRange(end, end);
});
};
const renderTags = () => {
tagsEl.innerHTML = "";
task.tags.forEach((tag) => {
const button = document.createElement("button");
button.className = "task-card-ui__tag";
button.type = "button";
button.textContent = tag;
button.title = `Hold to remove ${tag}`;
button.disabled = busy;
button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
clearHoldTimer();
button.classList.add("is-holding");
holdTimer = window.setTimeout(() => {
holdTimer = null;
button.classList.remove("is-holding");
if (window.confirm(`Remove ${tag} from this task?`)) {
void removeTag(tag);
}
}, 650);
});
["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => {
button.addEventListener(eventName, () => {
clearHoldTimer();
button.classList.remove("is-holding");
});
});
button.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
tagsEl.appendChild(button);
});
const addButton = document.createElement("button");
addButton.className = "task-card-ui__tag task-card-ui__tag--action";
addButton.type = "button";
addButton.textContent = "+";
addButton.title = "Add tag";
addButton.disabled = busy || !task.taskPath;
addButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void addTag();
});
tagsEl.appendChild(addButton);
};
const renderLaneMenu = () => {
laneMenuEl.innerHTML = "";
if (!task.taskPath) {
closeLaneMenu();
return;
}
taskMoveOptions(task.lane).forEach((option) => {
const button = document.createElement("button");
button.className = "task-card-ui__lane-menu-item";
button.type = "button";
button.textContent = option.label;
button.disabled = busy;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void moveTask(option.lane);
});
laneMenuEl.appendChild(button);
});
laneMenuEl.style.display = laneMenuOpen ? "flex" : "none";
};
const applyTheme = () => {
const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog;
cardEl.style.setProperty("--task-accent", theme.accent);
cardEl.style.setProperty("--task-accent-soft", theme.accentSoft);
cardEl.style.setProperty("--task-muted", theme.muted);
cardEl.style.setProperty("--task-button-ink", theme.buttonInk);
};
const render = () => {
applyTheme();
laneToggleEl.textContent = "";
const laneLabelEl = document.createElement("span");
laneLabelEl.className = "task-card-ui__lane";
laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task";
const caretEl = document.createElement("span");
caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`;
caretEl.textContent = "▾";
laneToggleEl.append(laneLabelEl, caretEl);
laneToggleEl.disabled = busy || !task.taskPath;
laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false");
setStatus(statusLabel, statusKind);
if (editingField === "title") {
renderInlineEditor(titleEl, "title", task.title, "");
} else {
titleEl.innerHTML = "";
const button = document.createElement("button");
button.className = "task-card-ui__title task-card-ui__text-button";
button.type = "button";
button.disabled = busy || !task.taskPath;
button.textContent = task.title || "(Untitled task)";
button.addEventListener("click", beginTitleEdit);
titleEl.appendChild(button);
}
const bodySummary = summarizeTaskBody(task);
if (editingField === "body") {
renderInlineEditor(bodyEl, "body", task.body, "Add description");
} else {
bodyEl.innerHTML = "";
const button = document.createElement("button");
button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${
bodySummary ? "" : " is-placeholder"
}`;
button.type = "button";
button.disabled = busy || !task.taskPath;
const inner = document.createElement("span");
inner.className = "task-card-ui__body-markdown-inner";
inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description";
button.appendChild(inner);
button.addEventListener("click", beginBodyEdit);
bodyEl.appendChild(button);
}
renderTags();
renderLaneMenu();
const dueText = formatTaskDue(task);
dueEl.textContent = dueText;
metaEl.style.display = dueText ? "flex" : "none";
publishLiveContent();
setBusy(busy);
};
const handleLaneToggle = (event) => {
event.preventDefault();
event.stopPropagation();
if (laneMenuOpen) closeLaneMenu();
else openLaneMenu();
render();
};
const handleDocumentPointerDown = (event) => {
if (!laneMenuOpen) return;
if (!(event.target instanceof Node)) return;
if (laneWrapEl.contains(event.target)) return;
closeLaneMenu();
render();
};
const handleEscape = (event) => {
if (event.key !== "Escape" || !laneMenuOpen) return;
closeLaneMenu();
render();
};
laneToggleEl.addEventListener("click", handleLaneToggle);
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleEscape);
render();
return {
update({ item: nextItem, state: nextState }) {
task = normalizeTask(nextState, nextItem.title);
errorText = "";
statusLabel = "";
statusKind = "neutral";
laneMenuOpen = false;
editingField = null;
render();
},
destroy() {
clearHoldTimer();
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleEscape);
laneToggleEl.removeEventListener("click", handleLaneToggle);
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,410 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const emptyEl = root.querySelector('[data-upcoming-empty]');
const listEl = root.querySelector('[data-upcoming-list]');
if (!(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement)) return;
const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const eventWindowHoursRaw = Number(state.event_window_hours);
const eventWindowHours = Number.isFinite(eventWindowHoursRaw) && eventWindowHoursRaw >= 1 ? Math.min(eventWindowHoursRaw, 168) : 36;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 4;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
? state.empty_text.trim()
: `No upcoming events in the next ${eventWindowHours} hours.`;
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const normalizeDateValue = (value) => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value.dateTime === 'string') return value.dateTime;
if (typeof value.date === 'string') return value.date;
}
return '';
};
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const eventTime = (value, allDay = false) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.NaN;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${allDay ? '12:00:00' : '00:00:00'}`
: raw;
return new Date(normalized).getTime();
};
const formatEventLabel = (event) => {
const raw = normalizeDateValue(event?.start);
if (!raw) return '--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const date = new Date(`${raw}T12:00:00`);
return `${date.toLocaleDateString([], { weekday: 'short' })} · all day`;
}
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleString([], {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
});
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveCalendarToolConfig = async () => {
const fallbackName = configuredCalendarToolName || 'mcp_home_assistant_calendar_get_events';
if (!host.listTools) {
return { name: fallbackName, availableCalendars: calendarNames };
}
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const resolveUpcomingEvents = async (toolConfig) => {
const now = Date.now();
const windowEnd = now + eventWindowHours * 60 * 60 * 1000;
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const upcomingEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: 'week',
});
const events = extractEvents(toolResult);
for (const event of events) {
const allDay = isAllDay(event?.start, event?.end);
const startTime = eventTime(event?.start, allDay);
if (!Number.isFinite(startTime) || startTime < now || startTime > windowEnd) continue;
upcomingEvents.push({ ...event, _calendarName: calendarName, _allDay: allDay, _startTime: startTime });
}
}
upcomingEvents.sort((left, right) => left._startTime - right._startTime);
return upcomingEvents.slice(0, maxEvents);
};
const resolveForecastBundle = async () => {
if (!forecastCommand) throw new Error('Missing forecast_command');
const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid forecast payload');
}
return payload;
};
const forecastTime = (entry) => {
const time = new Date(String(entry?.datetime || '')).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const nearestForecast = (entries, targetTime) => {
if (!Array.isArray(entries) || entries.length === 0 || !Number.isFinite(targetTime)) return null;
let bestEntry = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const entry of entries) {
const entryTime = forecastTime(entry);
if (!Number.isFinite(entryTime)) continue;
const distance = Math.abs(entryTime - targetTime);
if (distance < bestDistance) {
bestDistance = distance;
bestEntry = entry;
}
}
return bestDistance <= 3 * 60 * 60 * 1000 ? bestEntry : null;
};
const computeUpcomingScore = (events) => {
if (!Array.isArray(events) || events.length === 0) return 0;
const now = Date.now();
const soonestMs = events
.map((event) => Number(event?._startTime))
.filter((time) => Number.isFinite(time) && time >= now)
.sort((left, right) => left - right)[0];
const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
let score = 44;
if (soonestHours !== null) {
if (soonestHours <= 1) score = 100;
else if (soonestHours <= 3) score = 97;
else if (soonestHours <= 8) score = 94;
else if (soonestHours <= 24) score = 90;
else if (soonestHours <= 36) score = 86;
else if (soonestHours <= 48) score = 82;
else if (soonestHours <= 72) score = 76;
else if (soonestHours <= 168) score = 62;
}
score += Math.min(events.length, 3);
return Math.max(0, Math.min(100, Math.round(score)));
};
const metricValue = (value, fallback = '--') => {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
};
const createMetricCell = (glyph, label, value) => {
const cell = document.createElement('div');
cell.style.display = 'flex';
cell.style.alignItems = 'baseline';
cell.style.columnGap = '3px';
cell.style.flex = '0 0 auto';
cell.style.minWidth = '0';
cell.style.whiteSpace = 'nowrap';
cell.title = label;
const glyphEl = document.createElement('div');
glyphEl.style.fontSize = '0.54rem';
glyphEl.style.lineHeight = '1';
glyphEl.style.color = 'var(--theme-card-neutral-muted)';
glyphEl.style.fontWeight = '700';
glyphEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
glyphEl.style.flex = '0 0 auto';
glyphEl.textContent = glyph;
cell.appendChild(glyphEl);
const valueEl = document.createElement('div');
valueEl.style.fontSize = '0.53rem';
valueEl.style.lineHeight = '1.1';
valueEl.style.color = 'var(--theme-card-neutral-text)';
valueEl.style.fontWeight = '700';
valueEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
valueEl.style.whiteSpace = 'nowrap';
valueEl.style.overflow = 'hidden';
valueEl.style.textOverflow = 'ellipsis';
valueEl.style.textAlign = 'right';
valueEl.style.flex = '1 1 auto';
valueEl.textContent = metricValue(value);
cell.appendChild(valueEl);
return cell;
};
const renderEvents = (items, temperatureUnit, windSpeedUnit) => {
listEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, item] of items.entries()) {
const event = item.event;
const forecast = item.forecast;
const entry = document.createElement('li');
entry.style.padding = index === 0 ? '8px 0 0' : '8px 0 0';
entry.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const whenEl = document.createElement('div');
whenEl.style.fontSize = '0.72rem';
whenEl.style.lineHeight = '1.2';
whenEl.style.letterSpacing = '0.02em';
whenEl.style.color = 'var(--theme-card-neutral-muted)';
whenEl.style.fontWeight = '700';
whenEl.textContent = formatEventLabel(event);
entry.appendChild(whenEl);
const titleEl = document.createElement('div');
titleEl.style.marginTop = '3px';
titleEl.style.fontSize = '0.9rem';
titleEl.style.lineHeight = '1.25';
titleEl.style.color = 'var(--theme-card-neutral-text)';
titleEl.style.fontWeight = '700';
titleEl.style.whiteSpace = 'normal';
titleEl.style.wordBreak = 'break-word';
titleEl.textContent = String(event.summary || '(No title)');
entry.appendChild(titleEl);
const detailGrid = document.createElement('div');
detailGrid.style.marginTop = '4px';
detailGrid.style.display = 'flex';
detailGrid.style.flexWrap = 'nowrap';
detailGrid.style.alignItems = 'baseline';
detailGrid.style.gap = '6px';
detailGrid.style.overflowX = 'auto';
detailGrid.style.overflowY = 'hidden';
detailGrid.style.scrollbarWidth = 'none';
detailGrid.style.msOverflowStyle = 'none';
detailGrid.style.webkitOverflowScrolling = 'touch';
const tempValue = Number.isFinite(Number(forecast?.temperature))
? `${Math.round(Number(forecast.temperature))}${temperatureUnit || ''}`
: null;
const windValue = Number.isFinite(Number(forecast?.wind_speed))
? `${Math.round(Number(forecast.wind_speed))}${windSpeedUnit || ''}`
: null;
const rainValue = Number.isFinite(Number(forecast?.precipitation_probability))
? `${Math.round(Number(forecast.precipitation_probability))}%`
: null;
const uvValue = Number.isFinite(Number(forecast?.uv_index))
? `${Math.round(Number(forecast.uv_index))}`
: null;
detailGrid.appendChild(createMetricCell('\uf2c9', 'Temperature', tempValue));
detailGrid.appendChild(createMetricCell('\uef16', 'Wind', windValue));
detailGrid.appendChild(createMetricCell('\uf043', 'Rain', rainValue));
detailGrid.appendChild(createMetricCell('\uf522', 'UV', uvValue));
entry.appendChild(detailGrid);
listEl.appendChild(entry);
}
};
const refresh = async () => {
const snapshot = {
kind: 'upcoming_conditions',
event_window_hours: eventWindowHours,
updated_at: null,
events: [],
errors: {},
};
try {
const [toolConfig, forecastBundle] = await Promise.all([
resolveCalendarToolConfig(),
resolveForecastBundle(),
]);
const events = await resolveUpcomingEvents(toolConfig);
const nwsSource = forecastBundle?.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const uvSource = forecastBundle?.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
const temperatureUnit = String(nwsSource?.temperature_unit || uvSource?.temperature_unit || '°F');
const windSpeedUnit = String(nwsSource?.wind_speed_unit || uvSource?.wind_speed_unit || 'mph');
const mergedItems = events.map((event) => {
const nwsForecast = nearestForecast(nwsSource?.forecast, event._startTime);
const uvForecast = nearestForecast(uvSource?.forecast, event._startTime);
return {
event,
forecast: {
datetime: nwsForecast?.datetime || uvForecast?.datetime || null,
condition: nwsForecast?.condition || uvForecast?.condition || null,
temperature: nwsForecast?.temperature ?? uvForecast?.temperature ?? null,
apparent_temperature: uvForecast?.apparent_temperature ?? null,
precipitation_probability: nwsForecast?.precipitation_probability ?? uvForecast?.precipitation_probability ?? null,
wind_speed: nwsForecast?.wind_speed ?? uvForecast?.wind_speed ?? null,
uv_index: uvForecast?.uv_index ?? null,
},
};
});
renderEvents(mergedItems, temperatureUnit, windSpeedUnit);
snapshot.events = mergedItems.map((item) => ({
summary: String(item.event.summary || '(No title)'),
start: normalizeDateValue(item.event.start) || null,
end: normalizeDateValue(item.event.end) || null,
all_day: Boolean(item.event._allDay),
calendar_name: String(item.event._calendarName || ''),
forecast_time: item.forecast.datetime || null,
condition: item.forecast.condition || null,
temperature: Number.isFinite(Number(item.forecast.temperature)) ? Number(item.forecast.temperature) : null,
apparent_temperature: Number.isFinite(Number(item.forecast.apparent_temperature)) ? Number(item.forecast.apparent_temperature) : null,
precipitation_probability: Number.isFinite(Number(item.forecast.precipitation_probability)) ? Number(item.forecast.precipitation_probability) : null,
wind_speed: Number.isFinite(Number(item.forecast.wind_speed)) ? Number(item.forecast.wind_speed) : null,
uv_index: Number.isFinite(Number(item.forecast.uv_index)) ? Number(item.forecast.uv_index) : null,
}));
snapshot.score = computeUpcomingScore(events);
} catch (error) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
emptyEl.textContent = String(error);
snapshot.errors.load = String(error);
snapshot.score = 0;
}
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
snapshot.updated_at = updatedText;
updateLiveContent(snapshot);
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -0,0 +1,19 @@
{
"key": "upcoming-conditions-live",
"title": "Upcoming Events",
"notes": "Upcoming event card with raw event-time forecast context. Fill template_state with calendar_tool_name (defaults to calendar_get_events), calendar_names, forecast_tool_name (defaults to exec), forecast_command, event_window_hours, max_events, refresh_ms, and empty_text. The card joins calendar events to the nearest hourly forecast rows without generating suggestions.",
"example_state": {
"calendar_tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48",
"event_window_hours": 36,
"max_events": 3,
"refresh_ms": 900000,
"empty_text": "No upcoming events in the next 36 hours."
},
"created_at": "2026-03-16T14:00:00+00:00",
"updated_at": "2026-03-16T14:00:00+00:00"
}

View file

@ -0,0 +1,23 @@
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
src: url('/card-templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
</style>
<div data-upcoming-conditions-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:12px; border:1px solid var(--theme-card-neutral-border);">
<div style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:0.86rem; line-height:1.02; letter-spacing:-0.01em; color:var(--theme-card-neutral-text); font-weight:700;">Upcoming Events</div>
<div data-upcoming-empty style="display:none; margin-top:8px; color:var(--theme-card-neutral-subtle); font-size:0.9rem; line-height:1.4;">No upcoming events.</div>
<ul data-upcoming-list style="list-style:none; margin:6px 0 0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
</div>

View file

@ -0,0 +1,356 @@
export function mount({ root, state, host }) {
state = state || {};
const __cleanup = [];
const __setInterval = (...args) => {
const id = window.setInterval(...args);
__cleanup.push(() => window.clearInterval(id));
return id;
};
const __setTimeout = (...args) => {
const id = window.setTimeout(...args);
__cleanup.push(() => window.clearTimeout(id));
return id;
};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]');
const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]');
const rainEl = root.querySelector('[data-weather-rain]');
const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]');
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
const uvName = typeof state.uv_name === 'string' ? state.uv_name.trim() : '';
const morningStartHourRaw = Number(state.morning_start_hour);
const morningEndHourRaw = Number(state.morning_end_hour);
const morningScoreRaw = Number(state.morning_score);
const defaultScoreRaw = Number(state.default_score);
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const morningStartHour = Number.isFinite(morningStartHourRaw) ? morningStartHourRaw : 6;
const morningEndHour = Number.isFinite(morningEndHourRaw) ? morningEndHourRaw : 11;
const morningScore = Number.isFinite(morningScoreRaw) ? morningScoreRaw : 84;
const defaultScore = Number.isFinite(defaultScoreRaw) ? defaultScoreRaw : 38;
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const estimateUvIndex = (cloudCoverage) => {
const now = new Date();
const hour = now.getHours() + now.getMinutes() / 60;
const daylightPhase = Math.sin(((hour - 6) / 12) * Math.PI);
if (!Number.isFinite(daylightPhase) || daylightPhase <= 0) return 0;
const normalizedCloudCoverage = Number.isFinite(cloudCoverage)
? Math.min(Math.max(cloudCoverage, 0), 100)
: null;
const cloudFactor = normalizedCloudCoverage === null
? 1
: Math.max(0.2, 1 - normalizedCloudCoverage * 0.0065);
return Math.max(0, Math.round(7 * daylightPhase * cloudFactor));
};
const computeWeatherScore = () => {
const now = new Date();
const hour = now.getHours() + now.getMinutes() / 60;
if (hour >= morningStartHour && hour < morningEndHour) return morningScore;
return defaultScore;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'No tool',
error: errorText,
});
return;
}
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const [toolResult, forecastBundle] = await Promise.all([
host.callTool(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
temperatureName,
`${prefix} Temperature`,
]);
const humidityEntry = findEntry(entries, [
humidityName,
`${prefix} Humidity`,
]);
const uvSensorEntry = findEntry(entries, [
uvName,
`${prefix} UV index`,
]);
const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const uvSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
const windSpeed = Number(nwsEntry?.wind_speed);
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const liveUvValue = Number(uvSensorEntry?.state);
const forecastUvValue = Number(uvEntry?.uv_index);
const cloudCoverage = Number.isFinite(Number(nwsEntry?.cloud_coverage))
? Number(nwsEntry?.cloud_coverage)
: Number(uvSource?.forecast?.[0]?.cloud_coverage);
const estimatedUvValue = estimateUvIndex(cloudCoverage);
const uvValue = Number.isFinite(liveUvValue)
? liveUvValue
: (Number.isFinite(forecastUvValue) ? forecastUvValue : estimatedUvValue);
const uvEstimated = !Number.isFinite(liveUvValue) && !Number.isFinite(forecastUvValue);
uvEl.textContent = Number.isFinite(uvValue)
? `${uvEstimated && uvValue > 0 ? '~' : ''}${Math.round(uvValue)}`
: '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', 'var(--theme-status-live)');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
uv_estimated: uvEstimated,
score: computeWeatherScore(),
status: 'Live',
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', 'var(--theme-status-danger)');
tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: null,
temperature_unit: unitEl.textContent || null,
humidity: null,
wind: null,
rain: null,
uv: null,
score: computeWeatherScore(),
status: 'Unavailable',
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => { void refresh(); }, refreshMs);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,7 +1,7 @@
{
"key": "weather-live",
"title": "Live Weather",
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional uv_name, optional condition_label, optional morning_start_hour/morning_end_hour/morning_score/default_score, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload. If a live UV reading is unavailable, the card falls back to a clearly approximate current UV estimate.",
"example_state": {
"subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext",
@ -10,8 +10,13 @@
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
"uv_name": "OpenWeatherMap UV index",
"condition_label": "Weather",
"refresh_ms": 86400000
"morning_start_hour": 6,
"morning_end_hour": 11,
"morning_score": 84,
"default_score": 38,
"refresh_ms": 300000
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"

View file

@ -1,4 +1,4 @@
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
@ -9,322 +9,31 @@
}
</style>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div style="display:flex; align-items:flex-end; gap:8px; margin-bottom:4px;">
<span data-weather-temp style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">°F</span>
</div>
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
<div>
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]');
const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]');
const rainEl = root.querySelector('[data-weather-rain]');
const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]');
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await window.__nanobotCallTool?.(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', '#b91c1c');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'No tool',
error: errorText,
});
return;
}
setStatus('Refreshing', '#6b7280');
try {
const [toolResult, forecastBundle] = await Promise.all([
window.__nanobotCallTool?.(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
temperatureName,
`${prefix} Temperature`,
]);
const humidityEntry = findEntry(entries, [
humidityName,
`${prefix} Humidity`,
]);
const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const windSpeed = Number(nwsEntry?.wind_speed);
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const uvValue = Number(uvEntry?.uv_index);
uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', '#047857');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
status: 'Live',
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', '#b91c1c');
tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: null,
temperature_unit: unitEl.textContent || null,
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'Unavailable',
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -1,512 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { AgentIndicator } from "./components/AgentIndicator";
import { CardFeed } from "./components/CardFeed";
import { ControlBar, VoiceStatus } from "./components/Controls";
import { LogPanel } from "./components/LogPanel";
import { useAudioMeter } from "./hooks/useAudioMeter";
import { usePTT } from "./hooks/usePTT";
import { useState } from "preact/hooks";
import {
AgentWorkspace,
FeedWorkspace,
SwipeWorkspace,
useAppPresentation,
useSessionDrawerEdgeSwipe,
} from "./appShell/presentation";
import { VoiceStatus } from "./components/Controls";
import { SessionDrawer } from "./components/SessionDrawer";
import { WorkbenchOverlay } from "./components/WorkbenchOverlay";
import { useWebRTC } from "./hooks/useWebRTC";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardSelectionRange,
JsonValue,
LogLine,
} from "./types";
import type { ThemeName, ThemeOption } from "./theme/themes";
import { useThemePreference } from "./theme/useThemePreference";
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_DIRECTION_RATIO = 1.15;
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
type WorkspaceView = "agent" | "feed";
interface AppRtcActions {
connect(): Promise<void>;
sendJson(
msg:
| { type: "command"; command: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
): void;
setTextOnly(enabled: boolean): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
connected: boolean;
connecting: boolean;
}
function toNullableNumber(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
const raw = window.__nanobotGetCardSelection?.(cardId);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, JsonValue>;
if (record.kind !== "git_diff_range") return null;
if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
const label =
typeof record.label === "string"
? record.label
: `${record.file_label} · ${record.range_label}`;
return {
kind: "git_diff_range",
file_path: filePath,
file_label: record.file_label,
range_label: record.range_label,
label,
old_start: toNullableNumber(record.old_start),
old_end: toNullableNumber(record.old_end),
new_start: toNullableNumber(record.new_start),
new_end: toNullableNumber(record.new_end),
};
}
function buildCardMetadata(card: CardItem): CardMessageMetadata {
const metadata: CardMessageMetadata = {
card_id: card.serverId,
card_slot: card.slot,
card_title: card.title,
card_lane: card.lane,
card_template_key: card.templateKey,
card_context_summary: card.contextSummary,
card_response_value: card.responseValue,
};
const selection = card.serverId ? readCardSelection(card.serverId) : null;
if (selection) {
metadata.card_selection = selection as unknown as JsonValue;
metadata.card_selection_label = selection.label;
}
const liveContent = card.serverId
? window.__nanobotGetCardLiveContent?.(card.serverId)
: undefined;
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
return metadata;
}
function AgentCardContext({
card,
selection,
onClear,
}: {
card: CardItem;
selection: CardSelectionRange | null;
onClear(): void;
}) {
const label = selection ? "Using diff context" : "Using card";
const title = selection?.file_label || card.title;
const meta = selection?.range_label || "";
return (
<div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">{label}</div>
<div class="agent-card-context-row">
<div class="agent-card-context-main">
<div class="agent-card-context-title">{title}</div>
{meta && <div class="agent-card-context-meta">{meta}</div>}
</div>
<button
class="agent-card-context-clear"
type="button"
aria-label="Clear selected card context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClear();
}}
>
Clear
</button>
</div>
</div>
);
}
function useSwipeHandlers(
composing: boolean,
view: WorkspaceView,
setView: (view: WorkspaceView) => void,
isInteractiveTarget: (target: EventTarget | null) => boolean,
) {
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
const onSwipeStart = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (composing) return;
if (pe.pointerType === "mouse" && pe.button !== 0) return;
if (isInteractiveTarget(pe.target)) return;
swipeStartRef.current = { x: pe.clientX, y: pe.clientY };
},
[composing, isInteractiveTarget],
);
const onSwipeEnd = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
const start = swipeStartRef.current;
swipeStartRef.current = null;
if (!start || composing) return;
const dx = pe.clientX - start.x;
const dy = pe.clientY - start.y;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX) return;
if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO) return;
if (view === "agent" && dx < 0) setView("feed");
if (view === "feed" && dx > 0) setView("agent");
},
[composing, view, setView],
);
return { onSwipeStart, onSwipeEnd };
}
function useCardActions(
setView: (view: WorkspaceView) => void,
setSelectedCardId: (cardId: string | null) => void,
) {
const handleAskCard = useCallback(
(card: CardItem) => {
if (!card.serverId) return;
setSelectedCardId(card.serverId);
setView("agent");
},
[setSelectedCardId, setView],
);
return { handleAskCard };
}
function useGlobalPointerBindings({
handlePointerDown,
handlePointerMove,
handlePointerUp,
}: {
handlePointerDown: (event: Event) => void;
handlePointerMove: (event: Event) => void;
handlePointerUp: (event: Event) => void;
}) {
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointermove", handlePointerMove, { passive: true });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
}
function useControlActions(rtc: AppRtcActions) {
const handleReset = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]);
const handleToggleTextOnly = useCallback(
async (enabled: boolean) => {
rtc.setTextOnly(enabled);
if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
},
[rtc],
);
return { handleReset, handleToggleTextOnly };
}
function useSelectedCardActions({
function AgentPanel({
app,
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
sessionDrawerOpen,
setSessionDrawerOpen,
sessionDrawerEdgeGesture,
activeTheme,
themeOptions,
onSelectTheme,
}: {
rtc: AppRtcActions & {
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
};
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
selectedCardMetadata: () => CardMessageMetadata | undefined;
}) {
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId, setSelectedCardId]);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
},
[rtc],
);
const handleSendMessage = useCallback(
async (text: string) => {
await rtc.sendTextMessage(text, selectedCardMetadata());
},
[rtc, selectedCardMetadata],
);
const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
clearSelectedCardContext();
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [clearSelectedCardContext, rtc]);
return {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
handleResetWithSelection,
};
}
function useSelectedCardContext(
cards: CardItem[],
selectedCardId: string | null,
selectionVersion: number,
) {
const selectedCard = useMemo(
() =>
selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[cards, selectedCardId],
);
const selectedCardSelection = useMemo(
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
[selectedCardId, selectionVersion],
);
const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard, selectionVersion],
);
return { selectedCard, selectedCardSelection, selectedCardMetadata };
}
function useCardSelectionLifecycle({
cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
setView,
}: {
cards: CardItem[];
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
setSelectionVersion: (updater: (current: number) => number) => void;
setView: (view: WorkspaceView) => void;
}) {
const autoOpenedFeedRef = useRef(false);
useEffect(() => {
if (autoOpenedFeedRef.current || cards.length === 0) return;
autoOpenedFeedRef.current = true;
setView("feed");
}, [cards.length, setView]);
useEffect(() => {
if (!selectedCardId) return;
if (cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [cards, selectedCardId, setSelectedCardId]);
useEffect(() => {
const handleSelectionChange = (event: Event) => {
const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>)
.detail;
setSelectionVersion((current) => current + 1);
const cardId = typeof detail?.cardId === "string" ? detail.cardId : "";
if (cardId && detail?.selection) setSelectedCardId(cardId);
};
window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
return () => {
window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
};
}, [setSelectedCardId, setSelectionVersion]);
}
function AgentWorkspace({
active,
selectedCard,
selectedCardSelection,
onClearSelectedCardContext,
onReset,
textOnly,
onToggleTextOnly,
logLines,
connected,
onSendMessage,
onExpandChange,
effectiveAgentState,
connecting,
audioLevel,
}: {
active: boolean;
selectedCard: CardItem | null;
selectedCardSelection: CardSelectionRange | null;
onClearSelectedCardContext(): void;
onReset(): Promise<void>;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): Promise<void>;
logLines: LogLine[];
connected: boolean;
onSendMessage(text: string): Promise<void>;
onExpandChange(expanded: boolean): void;
effectiveAgentState: AgentState;
connecting: boolean;
audioLevel: number;
app: ReturnType<typeof useAppPresentation>;
rtc: ReturnType<typeof useWebRTC>;
sessionDrawerOpen: boolean;
setSessionDrawerOpen(open: boolean): void;
sessionDrawerEdgeGesture: ReturnType<typeof useSessionDrawerEdgeSwipe>;
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}) {
return (
<section class="workspace-panel workspace-agent">
{active && (
<ControlBar onReset={onReset} textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />
)}
{active && selectedCard && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={onClearSelectedCardContext}
<AgentWorkspace
active={app.view === "agent"}
selectedCard={app.selectedCard}
selectedCardSelection={app.selectedCardSelection}
selectedCardContextLabel={app.selectedCardContextLabel}
onClearSelectedCardContext={app.clearSelectedCardContext}
textOnly={rtc.textOnly}
onToggleTextOnly={app.handleToggleTextOnly}
sessionDrawerEdge={
!sessionDrawerOpen ? (
<div
id="session-drawer-edge"
data-no-swipe="1"
onTouchStart={sessionDrawerEdgeGesture.onTouchStart}
onTouchMove={sessionDrawerEdgeGesture.onTouchMove}
onTouchEnd={sessionDrawerEdgeGesture.onTouchEnd}
onTouchCancel={sessionDrawerEdgeGesture.onTouchCancel}
/>
) : null
}
logLines={rtc.logLines}
connected={rtc.connected}
onSendMessage={app.handleSendMessage}
effectiveAgentState={app.effectiveAgentState}
textStreaming={rtc.textStreaming}
connecting={rtc.connecting}
audioLevel={app.audioLevel}
sessionDrawer={
<SessionDrawer
open={sessionDrawerOpen}
progress={sessionDrawerEdgeGesture.progress}
dragging={sessionDrawerEdgeGesture.progress > 0 && !sessionDrawerOpen}
sessions={rtc.sessions}
activeSessionId={rtc.activeSessionId}
busy={rtc.sessionLoading || rtc.textStreaming}
onClose={() => setSessionDrawerOpen(false)}
onCreate={async () => {
await rtc.createSession();
setSessionDrawerOpen(false);
}}
onSelect={async (chatId) => {
await rtc.switchSession(chatId);
setSessionDrawerOpen(false);
}}
onRename={async (chatId, title) => {
await rtc.renameSession(chatId, title);
}}
onDelete={async (chatId) => {
await rtc.deleteSession(chatId);
setSessionDrawerOpen(false);
}}
activeTheme={activeTheme}
themeOptions={themeOptions}
onSelectTheme={onSelectTheme}
/>
)}
{active && (
<LogPanel
lines={logLines}
disabled={!connected}
onSendMessage={onSendMessage}
onExpandChange={onExpandChange}
}
workbenchOverlay={
<WorkbenchOverlay
items={rtc.workbenchItems}
onDismiss={rtc.dismissWorkbenchItem}
onPromote={rtc.promoteWorkbenchItem}
/>
)}
<AgentIndicator
state={effectiveAgentState}
connected={connected}
connecting={connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
</section>
}
/>
);
}
function FeedWorkspace({
cards,
onDismiss,
onChoice,
onAskCard,
function FeedPanel({
app,
rtc,
}: {
cards: CardItem[];
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
app: ReturnType<typeof useAppPresentation>;
rtc: ReturnType<typeof useWebRTC>;
}) {
return (
<section class="workspace-panel workspace-feed">
<CardFeed
cards={cards}
viewActive
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
</section>
<FeedWorkspace
cards={rtc.cards}
onDismiss={rtc.dismissCard}
onChoice={app.handleCardChoice}
onAskCard={app.handleAskCard}
/>
);
}
export function App() {
const rtc = useWebRTC();
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<WorkspaceView>("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
rtc.cards,
selectedCardId,
selectionVersion,
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly,
currentAgentState: rtc.agentState,
onSendPtt: (pressed) =>
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
onBootstrap: rtc.connect,
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
});
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("button,textarea,input,a,[data-no-swipe='1']"));
}, []);
const { onSwipeStart, onSwipeEnd } = useSwipeHandlers(
composing,
view,
setView,
isInteractiveTarget,
);
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
useCardSelectionLifecycle({
cards: rtc.cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
setView,
});
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
handleResetWithSelection,
} = useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
const app = useAppPresentation(rtc);
const theme = useThemePreference();
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const sessionDrawerEdgeGesture = useSessionDrawerEdgeSwipe({
enabled: app.view === "agent" && !sessionDrawerOpen,
onOpen: () => setSessionDrawerOpen(true),
});
return (
<>
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
<AgentWorkspace
active={view === "agent"}
selectedCard={selectedCard}
selectedCardSelection={selectedCardSelection}
onClearSelectedCardContext={clearSelectedCardContext}
onReset={handleResetWithSelection}
textOnly={rtc.textOnly}
onToggleTextOnly={handleToggleTextOnly}
logLines={rtc.logLines}
connected={rtc.connected}
onSendMessage={handleSendMessage}
onExpandChange={setComposing}
effectiveAgentState={effectiveAgentState}
connecting={rtc.connecting}
audioLevel={audioLevel}
<SwipeWorkspace
view={app.view}
trackStyle={app.trackStyle}
onSwipeStart={app.onSwipeStart}
onSwipeMove={app.onSwipeMove}
onSwipeEnd={app.onSwipeEnd}
onSwipeCancel={app.onSwipeCancel}
onTouchStart={app.onTouchStart}
onTouchMove={app.onTouchMove}
onTouchEnd={app.onTouchEnd}
onTouchCancel={app.onTouchCancel}
agentWorkspace={
<AgentPanel
app={app}
rtc={rtc}
sessionDrawerOpen={sessionDrawerOpen}
setSessionDrawerOpen={setSessionDrawerOpen}
sessionDrawerEdgeGesture={sessionDrawerEdgeGesture}
activeTheme={theme.themeName}
themeOptions={theme.themeOptions}
onSelectTheme={theme.setThemeName}
/>
<FeedWorkspace
cards={rtc.cards}
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</div>
</div>
}
feedWorkspace={<FeedPanel app={app} rtc={rtc} />}
/>
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
</>
);

View file

@ -0,0 +1,905 @@
import type { ComponentChildren } from "preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import {
clearCardSelection,
getCardLiveContent,
getCardSelection,
subscribeCardSelection,
} from "../cardRuntime/store";
import { AgentIndicator } from "../components/AgentIndicator";
import { CardFeed } from "../components/CardFeed";
import { ControlBar } from "../components/Controls";
import { LogPanel } from "../components/LogPanel";
import { useAudioMeter } from "../hooks/useAudioMeter";
import { usePTT } from "../hooks/usePTT";
import type { WebRTCState } from "../hooks/webrtc/types";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardSelectionRange,
JsonValue,
LogLine,
} from "../types";
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_INTENT_PX = 12;
const SWIPE_COMMIT_RATIO = 0.18;
const SWIPE_DIRECTION_RATIO = 1.15;
const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52;
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
const SESSION_DRAWER_GUTTER_PX = 28;
type WorkspaceView = "agent" | "feed";
interface AppRtcActions {
connect(): Promise<void>;
sendJson(
msg:
| { type: "command"; command: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
): void;
setTextOnly(enabled: boolean): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
connected: boolean;
connecting: boolean;
}
interface SwipeState {
pointerId: number;
x: number;
y: number;
dragging: boolean;
}
interface EdgeSwipeState {
identifier: number;
x: number;
y: number;
}
type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open";
function findTouchById(touches: TouchList, identifier: number): Touch | null {
for (let i = 0; i < touches.length; i += 1) {
const touch = touches.item(i);
if (touch && touch.identifier === identifier) return touch;
}
return null;
}
function isSwipeInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"),
);
}
function getViewportWidth(): number {
return window.innerWidth || document.documentElement.clientWidth || 1;
}
function toNullableNumber(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
const raw = getCardSelection(cardId);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, JsonValue>;
if (record.kind !== "git_diff_range") return null;
if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
const label =
typeof record.label === "string"
? record.label
: `${record.file_label} · ${record.range_label}`;
return {
kind: "git_diff_range",
file_path: filePath,
file_label: record.file_label,
range_label: record.range_label,
label,
old_start: toNullableNumber(record.old_start),
old_end: toNullableNumber(record.old_end),
new_start: toNullableNumber(record.new_start),
new_end: toNullableNumber(record.new_end),
};
}
function buildCardMetadata(card: CardItem): CardMessageMetadata {
const metadata: CardMessageMetadata = {
card_id: card.serverId,
card_slot: card.slot,
card_title: card.title,
card_lane: card.lane,
card_template_key: card.templateKey,
card_context_summary: card.contextSummary,
card_response_value: card.responseValue,
};
const selection = card.serverId ? readCardSelection(card.serverId) : null;
if (selection) {
metadata.card_selection = selection as unknown as JsonValue;
metadata.card_selection_label = selection.label;
}
const liveContent = card.serverId ? getCardLiveContent(card.serverId) : undefined;
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
return metadata;
}
function formatCardContextLabel(
card: CardItem | null,
selection: CardSelectionRange | null,
): string | null {
if (!card) return null;
if (selection) {
return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`;
}
const title = card.title?.trim();
if (!title) return "card";
if (card.templateKey === "todo-item-live") return `task: ${title}`;
if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`;
if (card.templateKey === "list-total-live") return `tracker: ${title}`;
return `card: ${title}`;
}
function AgentCardContext({
card,
selection,
onClear,
textMode = false,
}: {
card: CardItem;
selection: CardSelectionRange | null;
onClear(): void;
textMode?: boolean;
}) {
const label = textMode
? "Next message uses this context"
: selection
? "Using diff context"
: "Using card";
const title = selection?.file_label || card.title;
const meta = selection?.range_label || card.contextSummary || "";
return (
<div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">{label}</div>
<div class="agent-card-context-row">
<div class="agent-card-context-main">
<div class="agent-card-context-title">{title}</div>
{meta && <div class="agent-card-context-meta">{meta}</div>}
{textMode ? (
<div class="agent-card-context-note">
Ask your follow-up and Nanobot will include this card in the conversation context.
</div>
) : null}
</div>
<button
class="agent-card-context-clear"
type="button"
aria-label="Clear selected card context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClear();
}}
>
Clear
</button>
</div>
</div>
);
}
function getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } {
return {
dx: event.clientX - swipe.x,
dy: event.clientY - swipe.y,
};
}
function hasSwipeIntent(dx: number, dy: number): boolean {
return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX;
}
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO;
}
function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number {
const minOffset = view === "agent" ? -width : 0;
const maxOffset = view === "agent" ? 0 : width;
return Math.max(minOffset, Math.min(maxOffset, dx));
}
function getCommittedSwipeView(
view: WorkspaceView,
dx: number,
width: number,
): WorkspaceView | null {
const progress = width > 0 ? Math.abs(dx) / width : 0;
const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO;
if (!crossedThreshold) return null;
if (view === "agent" && dx < 0) return "feed";
if (view === "feed" && dx > 0) return "agent";
return null;
}
function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void {
const element = currentTarget as HTMLElement | null;
if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
}
export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) {
return useMemo(() => {
const base = view === "feed" ? "-50%" : "0%";
return {
transform:
swipeOffsetPx === 0
? `translateX(${base})`
: `translateX(calc(${base} + ${swipeOffsetPx}px))`,
transition: swiping ? "none" : "transform 0.28s ease",
};
}, [swipeOffsetPx, swiping, view]);
}
function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome {
if (!hasSwipeIntent(dx, dy)) return "idle";
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel";
if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track";
return "open";
}
function getEdgeSwipeTouch(
touches: TouchList,
swipe: EdgeSwipeState | null,
): { swipe: EdgeSwipeState; touch: Touch } | null {
if (!swipe) return null;
const touch = findTouchById(touches, swipe.identifier);
if (!touch) return null;
return { swipe, touch };
}
function getSessionDrawerWidth(viewportWidth: number): number {
return Math.max(
1,
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
);
}
function getSessionDrawerProgress(dx: number, viewportWidth: number): number {
return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth)));
}
export function useSessionDrawerEdgeSwipe({
enabled,
onOpen,
}: {
enabled: boolean;
onOpen(): void;
}) {
const edgeSwipeRef = useRef<EdgeSwipeState | null>(null);
const [progress, setProgress] = useState(0);
const clearEdgeSwipe = useCallback(() => {
edgeSwipeRef.current = null;
setProgress(0);
}, []);
const onTouchStart = useCallback(
(e: Event) => {
if (!enabled) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
edgeSwipeRef.current = {
identifier: touch.identifier,
x: touch.clientX,
y: touch.clientY,
};
},
[enabled],
);
const onTouchMove = useCallback(
(e: Event) => {
const te = e as TouchEvent;
if (!enabled) return;
const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current);
if (!trackedTouch) return;
const { swipe, touch } = trackedTouch;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
const outcome = getEdgeSwipeOutcome(dx, dy);
if (outcome === "idle") return;
if (outcome === "cancel") {
clearEdgeSwipe();
return;
}
if (te.cancelable) te.preventDefault();
setProgress(
getSessionDrawerProgress(
dx,
window.innerWidth || document.documentElement.clientWidth || 1,
),
);
if (outcome === "track") return;
},
[clearEdgeSwipe, enabled],
);
const onTouchEnd = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current);
if (!trackedTouch) {
clearEdgeSwipe();
return;
}
const { swipe, touch } = trackedTouch;
const dx = touch.clientX - swipe.x;
const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX;
clearEdgeSwipe();
if (shouldOpen) onOpen();
},
[clearEdgeSwipe, onOpen],
);
return {
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clearEdgeSwipe,
progress,
};
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here
export function useSwipeHandlers(
view: WorkspaceView,
setView: (view: WorkspaceView) => void,
isInteractiveTarget: (target: EventTarget | null) => boolean,
getViewportWidthFn: () => number,
) {
const swipeRef = useRef<SwipeState | null>(null);
const [swipeOffsetPx, setSwipeOffsetPx] = useState(0);
const [swiping, setSwiping] = useState(false);
const clearSwipe = useCallback(() => {
swipeRef.current = null;
setSwiping(false);
setSwipeOffsetPx(0);
}, []);
const onSwipeStart = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
if (pe.pointerType === "mouse" && pe.button !== 0) return;
if (isInteractiveTarget(pe.target)) return;
swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false };
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId);
},
[isInteractiveTarget],
);
const onSwipeMove = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
const swipe = swipeRef.current;
if (!swipe || swipe.pointerId !== pe.pointerId) return;
const { dx, dy } = getSwipeDelta(swipe, pe);
if (!swipe.dragging) {
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy)) {
clearSwipe();
return;
}
swipe.dragging = true;
setSwiping(true);
}
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
if (pe.cancelable) pe.preventDefault();
},
[clearSwipe, getViewportWidthFn, view],
);
const finishSwipe = useCallback(
(e: Event, commit: boolean) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
const swipe = swipeRef.current;
if (!swipe || swipe.pointerId !== pe.pointerId) return;
releaseCapturedPointer(e.currentTarget, pe.pointerId);
if (commit) {
const { dx } = getSwipeDelta(swipe, pe);
const nextView = swipe.dragging
? getCommittedSwipeView(view, dx, getViewportWidthFn())
: null;
if (nextView) setView(nextView);
}
clearSwipe();
},
[clearSwipe, getViewportWidthFn, setView, view],
);
const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]);
const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]);
const onTouchStart = useCallback(
(e: Event) => {
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
if (isInteractiveTarget(te.target)) return;
swipeRef.current = {
pointerId: touch.identifier,
x: touch.clientX,
y: touch.clientY,
dragging: false,
};
},
[isInteractiveTarget],
);
const onTouchMove = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const swipe = swipeRef.current;
if (!swipe) return;
const touch = findTouchById(te.touches, swipe.pointerId);
if (!touch) return;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (!swipe.dragging) {
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy)) {
clearSwipe();
return;
}
swipe.dragging = true;
setSwiping(true);
}
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
if (te.cancelable) te.preventDefault();
},
[clearSwipe, getViewportWidthFn, view],
);
const onTouchEnd = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const swipe = swipeRef.current;
if (!swipe) return;
const touch = findTouchById(te.changedTouches, swipe.pointerId);
if (!touch) return;
if (swipe.dragging) {
const dx = touch.clientX - swipe.x;
const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn());
if (nextView) setView(nextView);
}
clearSwipe();
},
[clearSwipe, getViewportWidthFn, setView, view],
);
const onTouchCancel = useCallback(() => {
clearSwipe();
}, [clearSwipe]);
return {
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
swipeOffsetPx,
swiping,
};
}
function useCardActions(
setView: (view: WorkspaceView) => void,
setSelectedCardId: (cardId: string | null) => void,
) {
const handleAskCard = useCallback(
(card: CardItem) => {
if (!card.serverId) return;
setSelectedCardId(card.serverId);
setView("agent");
},
[setSelectedCardId, setView],
);
return { handleAskCard };
}
function useGlobalPointerBindings({
handlePointerDown,
handlePointerMove,
handlePointerUp,
}: {
handlePointerDown: (event: Event) => void;
handlePointerMove: (event: Event) => void;
handlePointerUp: (event: Event) => void;
}) {
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointermove", handlePointerMove, { passive: true });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
}
function useControlActions(rtc: AppRtcActions) {
const handleToggleTextOnly = useCallback(
async (enabled: boolean) => {
rtc.setTextOnly(enabled);
if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
},
[rtc],
);
return { handleToggleTextOnly };
}
function useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
}: {
rtc: AppRtcActions & {
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
};
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
selectedCardMetadata: () => CardMessageMetadata | undefined;
}) {
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) clearCardSelection(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId, setSelectedCardId]);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
},
[rtc],
);
const handleSendMessage = useCallback(
async (text: string) => {
await rtc.sendTextMessage(text, selectedCardMetadata());
},
[rtc, selectedCardMetadata],
);
return {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
};
}
function useSelectedCardContext(
cards: CardItem[],
selectedCardId: string | null,
selectionVersion: number,
) {
const selectedCard = useMemo(
() =>
selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[cards, selectedCardId],
);
const selectedCardSelection = useMemo(
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
[selectedCardId, selectionVersion],
);
const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard, selectionVersion],
);
return { selectedCard, selectedCardSelection, selectedCardMetadata };
}
function useCardSelectionLifecycle({
cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
}: {
cards: CardItem[];
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
setSelectionVersion: (updater: (current: number) => number) => void;
}) {
useEffect(() => {
if (!selectedCardId) return;
if (cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [cards, selectedCardId, setSelectedCardId]);
useEffect(() => {
const unsubscribe = subscribeCardSelection((cardId, selection) => {
setSelectionVersion((current) => current + 1);
if (cardId && selection) setSelectedCardId(cardId);
});
return unsubscribe;
}, [setSelectedCardId, setSelectionVersion]);
}
export function AgentWorkspace({
active,
selectedCard,
selectedCardSelection,
selectedCardContextLabel,
onClearSelectedCardContext,
textOnly,
onToggleTextOnly,
sessionDrawerEdge,
logLines,
connected,
onSendMessage,
effectiveAgentState,
textStreaming,
connecting,
audioLevel,
sessionDrawer,
workbenchOverlay,
}: {
active: boolean;
selectedCard: CardItem | null;
selectedCardSelection: CardSelectionRange | null;
selectedCardContextLabel: string | null;
onClearSelectedCardContext(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): Promise<void>;
sessionDrawerEdge: ComponentChildren;
logLines: LogLine[];
connected: boolean;
onSendMessage(text: string): Promise<void>;
effectiveAgentState: AgentState;
textStreaming: boolean;
connecting: boolean;
audioLevel: number;
sessionDrawer: ComponentChildren;
workbenchOverlay: ComponentChildren;
}) {
return (
<section
class={`workspace-panel workspace-agent${textOnly ? " text-mode" : ""}${
textOnly && selectedCard ? " has-card-context" : ""
}`}
>
{active && <ControlBar textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />}
{active && sessionDrawerEdge}
{active && sessionDrawer}
{active && workbenchOverlay}
{active && selectedCard && !textOnly && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={onClearSelectedCardContext}
textMode={textOnly}
/>
)}
{active && (
<LogPanel
lines={logLines}
disabled={false}
onSendMessage={onSendMessage}
onOpenVoiceMode={() => {
void onToggleTextOnly(false);
}}
contextLabel={selectedCardContextLabel}
onClearContext={selectedCard ? onClearSelectedCardContext : undefined}
agentState={effectiveAgentState}
textStreaming={textStreaming}
fullScreen={textOnly}
/>
)}
{!textOnly && (
<AgentIndicator
state={effectiveAgentState}
connected={connected}
connecting={connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
)}
</section>
);
}
export function FeedWorkspace({
cards,
onDismiss,
onChoice,
onAskCard,
}: {
cards: CardItem[];
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}) {
return (
<section class="workspace-panel workspace-feed">
<CardFeed
cards={cards}
viewActive
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
</section>
);
}
export function SwipeWorkspace({
view,
trackStyle,
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
agentWorkspace,
feedWorkspace,
}: {
view: WorkspaceView;
trackStyle: Record<string, string>;
onSwipeStart: (event: Event) => void;
onSwipeMove: (event: Event) => void;
onSwipeEnd: (event: Event) => void;
onSwipeCancel: (event: Event) => void;
onTouchStart: (event: Event) => void;
onTouchMove: (event: Event) => void;
onTouchEnd: (event: Event) => void;
onTouchCancel: (event: Event) => void;
agentWorkspace: ComponentChildren;
feedWorkspace: ComponentChildren;
}) {
return (
<div
id="swipe-shell"
onPointerDown={onSwipeStart}
onPointerMove={onSwipeMove}
onPointerUp={onSwipeEnd}
onPointerCancel={onSwipeCancel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>
<div id="swipe-track" data-view={view} style={trackStyle}>
{agentWorkspace}
{feedWorkspace}
</div>
</div>
);
}
export function useAppPresentation(rtc: WebRTCState) {
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<WorkspaceView>("agent");
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
rtc.cards,
selectedCardId,
selectionVersion,
);
const selectedCardContextLabel = useMemo(
() => formatCardContextLabel(selectedCard, selectedCardSelection),
[selectedCard, selectedCardSelection],
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly,
currentAgentState: rtc.agentState,
onSendPtt: (pressed) =>
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
onBootstrap: rtc.textOnly ? async () => {} : rtc.connect,
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
});
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
const {
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
swipeOffsetPx,
swiping,
} = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth);
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
useCardSelectionLifecycle({
cards: rtc.cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
});
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata: useCallback(() => {
const metadata = selectedCardMetadata();
if (!metadata) return metadata;
return {
...metadata,
context_label: selectedCardContextLabel ?? undefined,
};
}, [selectedCardContextLabel, selectedCardMetadata]),
});
const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping);
return {
audioLevel,
view,
selectedCard,
selectedCardSelection,
selectedCardContextLabel,
effectiveAgentState,
handleToggleTextOnly,
handleAskCard,
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
trackStyle,
};
}

View file

@ -0,0 +1,363 @@
import { streamSseResponse } from "../lib/sse";
import type { JsonValue, WorkbenchItem } from "../types";
export interface ManualToolResult {
tool_name: string;
content: string;
parsed: JsonValue | null;
is_json: boolean;
}
export interface ManualToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
kind?: string;
}
export interface ManualToolJob {
job_id: string;
tool_name: string;
status: "queued" | "running" | "completed" | "failed";
created_at: string;
started_at: string | null;
finished_at: string | null;
result: ManualToolResult | null;
error: string | null;
error_code: number | null;
}
export interface ManualToolAsyncOptions {
timeoutMs?: number;
}
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return value;
}
}
export async function decodeJsonError(resp: Response): Promise<string> {
try {
const payload = (await resp.json()) as { error?: unknown };
if (payload && typeof payload === "object" && typeof payload.error === "string") {
return payload.error;
}
} catch {
// Fall back to status code.
}
return `request failed (${resp.status})`;
}
function normalizeManualToolResult(
payload: Partial<ManualToolResult> | null | undefined,
fallbackName: string,
): ManualToolResult {
return {
tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName,
content: typeof payload?.content === "string" ? payload.content : "",
parsed:
payload?.parsed === null || payload?.parsed === undefined
? null
: (cloneJsonValue(payload.parsed as JsonValue) ?? null),
is_json: payload?.is_json === true,
};
}
function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob {
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName;
const statusValue = typeof record.status === "string" ? record.status : "queued";
return {
job_id: typeof record.job_id === "string" ? record.job_id : "",
tool_name: toolName,
status:
statusValue === "running" || statusValue === "completed" || statusValue === "failed"
? statusValue
: "queued",
created_at: typeof record.created_at === "string" ? record.created_at : "",
started_at: typeof record.started_at === "string" ? record.started_at : null,
finished_at: typeof record.finished_at === "string" ? record.finished_at : null,
result: normalizeManualToolJobResult(record.result, toolName),
error: typeof record.error === "string" ? record.error : null,
error_code: typeof record.error_code === "number" ? record.error_code : null,
};
}
function normalizeManualToolAsyncOptions(
options: ManualToolAsyncOptions,
): Required<ManualToolAsyncOptions> {
const timeoutMs =
typeof options.timeoutMs === "number" &&
Number.isFinite(options.timeoutMs) &&
options.timeoutMs >= 100
? Math.round(options.timeoutMs)
: 30000;
return { timeoutMs };
}
function normalizeManualToolJobResult(
payload: unknown,
fallbackName: string,
): ManualToolResult | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const record = payload as Record<string, unknown>;
const looksNormalized =
typeof record.tool_name === "string" ||
typeof record.content === "string" ||
record.parsed !== undefined ||
typeof record.is_json === "boolean";
if (looksNormalized) {
return normalizeManualToolResult(record as Partial<ManualToolResult>, fallbackName);
}
return {
tool_name: fallbackName,
content: JSON.stringify(record),
parsed: cloneJsonValue(record as JsonValue) ?? null,
is_json: true,
};
}
function completedManualToolResult(
job: ManualToolJob,
toolName: string,
): ManualToolResult | undefined {
if (job.status !== "completed") return undefined;
return job.result ?? normalizeManualToolResult(null, toolName);
}
function assertManualToolJobDidNotFail(job: ManualToolJob, toolName: string): void {
if (job.status === "failed") {
throw new Error(job.error || `${toolName} failed`);
}
}
async function streamManualToolJobUpdates(
jobId: string,
toolName: string,
timeoutMs: number,
): Promise<ManualToolJob | null> {
let current: ManualToolJob | null = null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(`/tools/jobs/${encodeURIComponent(jobId)}/stream`, {
cache: "no-store",
signal: controller.signal,
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
await streamSseResponse(resp, (raw) => {
let payload: unknown = null;
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (!payload || typeof payload !== "object") return;
const record = payload as Record<string, unknown>;
if (record.type !== "tool.job") return;
current = normalizeManualToolJob(record.job, toolName);
});
} catch (error) {
if (controller.signal.aborted) {
throw new Error(`${toolName} timed out`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
return current;
}
async function waitForManualToolJob(
job: ManualToolJob,
toolName: string,
timeoutMs: number,
): Promise<ManualToolResult> {
const immediate = completedManualToolResult(job, toolName);
if (immediate) return immediate;
assertManualToolJobDidNotFail(job, toolName);
const streamed = await streamManualToolJobUpdates(job.job_id, toolName, timeoutMs);
const streamedResult = streamed ? completedManualToolResult(streamed, toolName) : undefined;
if (streamedResult) return streamedResult;
if (streamed) assertManualToolJobDidNotFail(streamed, toolName);
const current = await getManualToolJob(job.job_id);
const finalResult = completedManualToolResult(current, toolName);
if (finalResult) return finalResult;
assertManualToolJobDidNotFail(current, toolName);
throw new Error(`${toolName} ended before completion`);
}
export async function callManualTool(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolResult> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool response");
}
return normalizeManualToolResult(payload as Partial<ManualToolResult>, name);
}
export async function startManualToolCall(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolJob> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
const job = normalizeManualToolJob(payload, name);
if (!job.job_id) throw new Error("tool job id is required");
return job;
}
export async function getManualToolJob(jobId: string): Promise<ManualToolJob> {
const key = jobId.trim();
if (!key) throw new Error("tool job id is required");
const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
return normalizeManualToolJob(payload, "");
}
export async function callManualToolAsync(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
options: ManualToolAsyncOptions = {},
): Promise<ManualToolResult> {
const { timeoutMs } = normalizeManualToolAsyncOptions(options);
const job = await startManualToolCall(toolName, argumentsValue);
return waitForManualToolJob(job, toolName, timeoutMs);
}
export async function listManualTools(): Promise<ManualToolDefinition[]> {
const resp = await fetch("/tools", { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { tools?: unknown };
const tools = Array.isArray(payload?.tools) ? payload.tools : [];
return tools
.filter((tool): tool is Record<string, unknown> => !!tool && typeof tool === "object")
.map((tool) => ({
name: typeof tool.name === "string" ? tool.name : "",
description: typeof tool.description === "string" ? tool.description : "",
parameters:
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? (tool.parameters as Record<string, unknown>)
: {},
kind: typeof tool.kind === "string" ? tool.kind : undefined,
}))
.filter((tool) => tool.name);
}
export async function updateCardTemplateState(
cardId: string,
templateState: Record<string, JsonValue>,
): Promise<void> {
const key = cardId.trim();
if (!key) throw new Error("card id is required");
const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_state: templateState }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
}
export async function updateWorkbenchTemplateState(
item: WorkbenchItem,
templateState: Record<string, JsonValue>,
): Promise<void> {
const resp = await fetch("/workbench", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
chat_id: item.chatId,
kind: item.kind,
title: item.title,
content: item.content,
question: item.question || "",
choices: item.choices || [],
response_value: item.responseValue || "",
slot: item.slot || "",
template_key: item.templateKey || "",
template_state: templateState,
context_summary: item.contextSummary || "",
promotable: item.promotable,
source_card_id: item.sourceCardId || "",
}),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
}
export async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} finally {
textarea.remove();
}
}

View file

@ -0,0 +1,81 @@
import type { JsonValue } from "../types";
import type { RuntimeItem, RuntimeModule } from "./runtimeTypes";
import { runtimeItemId } from "./runtimeUtils";
const runtimeAssetCache = new Map<
string,
Promise<{ html: string | null; module: RuntimeModule } | null>
>();
function runtimeTemplateUrl(templateKey: string, filename: string): string {
return `/card-templates/${encodeURIComponent(templateKey)}/${filename}`;
}
function escapeAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function jsonScriptText(payload: Record<string, JsonValue>): string {
return JSON.stringify(payload, null, 0).replace(/<\//g, "<\\/");
}
export function materializeRuntimeHtml(
templateHtml: string,
item: RuntimeItem,
state: Record<string, JsonValue>,
): string {
const cardId = escapeAttribute(runtimeItemId(item));
const templateKey = escapeAttribute(item.templateKey?.trim() || "");
return [
`<div data-nanobot-card-root data-card-id="${cardId}" data-template-key="${templateKey}">`,
`<script type="application/json" data-card-state>${jsonScriptText(state)}</script>`,
templateHtml,
"</div>",
].join("");
}
export function syncRuntimeStateScript(
root: HTMLDivElement | null,
state: Record<string, JsonValue>,
): void {
const stateEl = root?.querySelector('script[data-card-state][type="application/json"]');
if (!(stateEl instanceof HTMLScriptElement)) return;
stateEl.textContent = jsonScriptText(state);
}
export async function loadRuntimeAssets(
templateKey: string,
): Promise<{ html: string | null; module: RuntimeModule } | null> {
const cached = runtimeAssetCache.get(templateKey);
if (cached) return cached;
const loader = (async () => {
const moduleUrl = runtimeTemplateUrl(templateKey, "card.js");
const moduleProbe = await fetch(moduleUrl, { cache: "no-store" });
if (!moduleProbe.ok) return null;
const templatePromise = fetch(runtimeTemplateUrl(templateKey, "template.html"), {
cache: "no-store",
})
.then(async (response) => (response.ok ? response.text() : null))
.catch(() => null);
const namespace = (await import(/* @vite-ignore */ `${moduleUrl}?runtime=1`)) as
| RuntimeModule
| { default?: RuntimeModule };
const runtimeModule = ("default" in namespace ? namespace.default : namespace) as RuntimeModule;
if (!runtimeModule || typeof runtimeModule.mount !== "function") return null;
return {
html: await templatePromise,
module: runtimeModule,
};
})();
runtimeAssetCache.set(templateKey, loader);
return loader;
}

View file

@ -0,0 +1,98 @@
import { marked } from "marked";
import { useRef } from "preact/hooks";
import type { JsonValue } from "../types";
import {
callManualTool,
callManualToolAsync,
copyTextToClipboard,
getManualToolJob,
listManualTools,
startManualToolCall,
updateCardTemplateState,
updateWorkbenchTemplateState,
} from "./api";
import type { RuntimeHost, RuntimeItem, RuntimeSurface } from "./runtimeTypes";
import { normalizeTemplateState, runtimeItemId } from "./runtimeUtils";
import {
clearCardSelection,
getCardLiveContent,
getCardSelection,
requestCardFeedRefresh,
runCardRefresh,
setCardLiveContent,
setCardRefreshHandler,
setCardSelection,
} from "./store";
export function useRuntimeHost(
surface: RuntimeSurface,
item: RuntimeItem,
stateRef: { current: Record<string, JsonValue> },
setState: (value: Record<string, JsonValue>) => void,
) {
const itemRef = useRef(item);
itemRef.current = item;
const hostRef = useRef<RuntimeHost | null>(null);
if (!hostRef.current) {
hostRef.current = {
surface,
item,
getState: () => normalizeTemplateState(stateRef.current),
replaceState: async (nextState) => {
const normalized = normalizeTemplateState(nextState);
stateRef.current = normalized;
setState(normalized);
const currentItem = itemRef.current;
if ("serverId" in currentItem) {
if (!currentItem.serverId) throw new Error("card id is required");
await updateCardTemplateState(currentItem.serverId, normalized);
} else if ("chatId" in currentItem) {
await updateWorkbenchTemplateState(currentItem, normalized);
}
return normalizeTemplateState(normalized);
},
patchState: async (patch) => {
const nextState = { ...stateRef.current, ...normalizeTemplateState(patch) };
return hostRef.current?.replaceState(nextState) ?? normalizeTemplateState(nextState);
},
setLiveContent: (snapshot) => {
setCardLiveContent(runtimeItemId(itemRef.current), snapshot);
},
getLiveContent: () => getCardLiveContent(runtimeItemId(itemRef.current)),
setSelection: (selection) => {
setCardSelection(runtimeItemId(itemRef.current), selection);
},
getSelection: () => getCardSelection(runtimeItemId(itemRef.current)),
clearSelection: () => {
clearCardSelection(runtimeItemId(itemRef.current));
},
setRefreshHandler: (handler) => {
setCardRefreshHandler(runtimeItemId(itemRef.current), handler);
},
runRefresh: () => runCardRefresh(runtimeItemId(itemRef.current)),
requestFeedRefresh: () => {
requestCardFeedRefresh();
},
callTool: callManualTool,
startToolCall: startManualToolCall,
getToolJob: getManualToolJob,
callToolAsync: callManualToolAsync,
listTools: listManualTools,
renderMarkdown: (markdown, options = {}) =>
options.inline
? (marked.parseInline(markdown) as string)
: (marked.parse(markdown) as string),
copyText: copyTextToClipboard,
getThemeName: () => document.documentElement.dataset.theme || "clay",
getThemeValue: (tokenName) => {
const normalized = tokenName.startsWith("--") ? tokenName : `--${tokenName}`;
return getComputedStyle(document.documentElement).getPropertyValue(normalized).trim();
},
};
}
hostRef.current.surface = surface;
hostRef.current.item = item;
return hostRef.current;
}

View file

@ -0,0 +1,58 @@
import type { CardItem, JsonValue, WorkbenchItem } from "../types";
import type {
ManualToolAsyncOptions,
ManualToolDefinition,
ManualToolJob,
ManualToolResult,
} from "./api";
export type RuntimeSurface = "feed" | "workbench";
export type RuntimeItem = CardItem | WorkbenchItem;
export interface RuntimeHost {
surface: RuntimeSurface;
item: RuntimeItem;
getState(): Record<string, JsonValue>;
replaceState(nextState: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
patchState(patch: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
setLiveContent(snapshot: JsonValue | null | undefined): void;
getLiveContent(): JsonValue | undefined;
setSelection(selection: JsonValue | null | undefined): void;
getSelection(): JsonValue | undefined;
clearSelection(): void;
setRefreshHandler(handler: (() => void) | null | undefined): void;
runRefresh(): boolean;
requestFeedRefresh(): void;
callTool(toolName: string, argumentsValue?: Record<string, JsonValue>): Promise<ManualToolResult>;
startToolCall(
toolName: string,
argumentsValue?: Record<string, JsonValue>,
): Promise<ManualToolJob>;
getToolJob(jobId: string): Promise<ManualToolJob>;
callToolAsync(
toolName: string,
argumentsValue?: Record<string, JsonValue>,
options?: ManualToolAsyncOptions,
): Promise<ManualToolResult>;
listTools(): Promise<ManualToolDefinition[]>;
renderMarkdown(markdown: string, options?: { inline?: boolean }): string;
copyText(text: string): Promise<void>;
getThemeName(): string;
getThemeValue(tokenName: string): string;
}
export interface RuntimeContext {
root: HTMLElement;
item: RuntimeItem;
state: Record<string, JsonValue>;
host: RuntimeHost;
}
export interface MountedRuntimeCard {
update?(context: RuntimeContext): void;
destroy?(): void;
}
export interface RuntimeModule {
mount(context: RuntimeContext): MountedRuntimeCard | undefined;
}

View file

@ -0,0 +1,35 @@
import type { CardItem, JsonValue } from "../types";
import type { RuntimeItem } from "./runtimeTypes";
export function cloneJsonValue<T extends JsonValue>(value: T | null | undefined): T | undefined {
if (value === null || value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value ?? undefined;
}
}
export function normalizeTemplateState(
value: Record<string, JsonValue> | undefined,
): Record<string, JsonValue> {
const cloned = cloneJsonValue((value || {}) as JsonValue);
return cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
}
export function runtimeItemId(item: RuntimeItem): string {
if (isCardItem(item)) {
return `card:${item.serverId || item.id}`;
}
return `workbench:${item.chatId}:${item.id}`;
}
function isCardItem(item: RuntimeItem): item is CardItem {
return "lane" in item;
}
export function looksLikeHtml(content: string): boolean {
return /^\s*<[a-zA-Z]/.test(content);
}

View file

@ -0,0 +1,192 @@
import type { JsonValue } from "../types";
type LiveContentListener = (cardId: string, snapshot: JsonValue | undefined) => void;
type SelectionListener = (cardId: string, selection: JsonValue | undefined) => void;
class CardRuntimeRegistry {
private readonly liveContentStore = new Map<string, JsonValue>();
private readonly selectionStore = new Map<string, JsonValue>();
private readonly refreshHandlers = new Map<string, () => void>();
private readonly liveContentListeners = new Set<LiveContentListener>();
private readonly selectionListeners = new Set<SelectionListener>();
private emitLiveContent(cardId: string, snapshot: JsonValue | undefined): void {
for (const listener of this.liveContentListeners) listener(cardId, cloneJsonValue(snapshot));
}
private emitSelection(cardId: string, selection: JsonValue | undefined): void {
for (const listener of this.selectionListeners) listener(cardId, cloneJsonValue(selection));
}
setCardLiveContent(
cardId: string | null | undefined,
snapshot: JsonValue | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
this.liveContentStore.delete(key);
this.emitLiveContent(key, undefined);
return;
}
this.liveContentStore.set(key, cloned);
this.emitLiveContent(key, cloned);
}
getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = String(cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(this.liveContentStore.get(key));
}
subscribeCardLiveContent(listener: LiveContentListener): () => void {
this.liveContentListeners.add(listener);
return () => {
this.liveContentListeners.delete(listener);
};
}
setCardSelection(
cardId: string | null | undefined,
selection: JsonValue | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
const cloned = cloneJsonValue(selection ?? undefined);
if (cloned === undefined) {
this.selectionStore.delete(key);
this.emitSelection(key, undefined);
return;
}
this.selectionStore.set(key, cloned);
this.emitSelection(key, cloned);
}
getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
const key = String(cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(this.selectionStore.get(key));
}
clearCardSelection(cardId: string | null | undefined): void {
const key = String(cardId || "").trim();
if (!key) return;
this.selectionStore.delete(key);
this.emitSelection(key, undefined);
}
subscribeCardSelection(listener: SelectionListener): () => void {
this.selectionListeners.add(listener);
return () => {
this.selectionListeners.delete(listener);
};
}
setCardRefreshHandler(
cardId: string | null | undefined,
handler: (() => void) | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
if (typeof handler !== "function") {
this.refreshHandlers.delete(key);
return;
}
this.refreshHandlers.set(key, handler);
}
hasCardRefreshHandler(cardId: string | null | undefined): boolean {
const key = String(cardId || "").trim();
if (!key) return false;
return this.refreshHandlers.has(key);
}
runCardRefresh(cardId: string | null | undefined): boolean {
const key = String(cardId || "").trim();
if (!key) return false;
const handler = this.refreshHandlers.get(key);
if (!handler) return false;
handler();
return true;
}
disposeCardRuntimeEntry(cardId: string | null | undefined): void {
const key = String(cardId || "").trim();
if (!key) return;
this.liveContentStore.delete(key);
this.selectionStore.delete(key);
this.refreshHandlers.delete(key);
this.emitLiveContent(key, undefined);
this.emitSelection(key, undefined);
}
}
const registry = new CardRuntimeRegistry();
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return value;
}
}
export function setCardLiveContent(
cardId: string | null | undefined,
snapshot: JsonValue | null | undefined,
): void {
registry.setCardLiveContent(cardId, snapshot);
}
export function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
return registry.getCardLiveContent(cardId);
}
export function subscribeCardLiveContent(listener: LiveContentListener): () => void {
return registry.subscribeCardLiveContent(listener);
}
export function setCardSelection(
cardId: string | null | undefined,
selection: JsonValue | null | undefined,
): void {
registry.setCardSelection(cardId, selection);
}
export function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
return registry.getCardSelection(cardId);
}
export function clearCardSelection(cardId: string | null | undefined): void {
registry.clearCardSelection(cardId);
}
export function subscribeCardSelection(listener: SelectionListener): () => void {
return registry.subscribeCardSelection(listener);
}
export function setCardRefreshHandler(
cardId: string | null | undefined,
handler: (() => void) | null | undefined,
): void {
registry.setCardRefreshHandler(cardId, handler);
}
export function hasCardRefreshHandler(cardId: string | null | undefined): boolean {
return registry.hasCardRefreshHandler(cardId);
}
export function runCardRefresh(cardId: string | null | undefined): boolean {
return registry.runCardRefresh(cardId);
}
export function disposeCardRuntimeEntry(cardId: string | null | undefined): void {
registry.disposeCardRuntimeEntry(cardId);
}
export function requestCardFeedRefresh(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event("nanobot:cards-refresh"));
}

View file

@ -0,0 +1,124 @@
import { marked } from "marked";
import { useEffect, useRef, useState } from "preact/hooks";
import {
loadRuntimeAssets,
materializeRuntimeHtml,
syncRuntimeStateScript,
} from "../cardRuntime/runtimeAssets";
import { useRuntimeHost } from "../cardRuntime/runtimeHost";
import type { MountedRuntimeCard, RuntimeItem, RuntimeSurface } from "../cardRuntime/runtimeTypes";
import { looksLikeHtml, normalizeTemplateState, runtimeItemId } from "../cardRuntime/runtimeUtils";
import { disposeCardRuntimeEntry } from "../cardRuntime/store";
import type { JsonValue } from "../types";
function StaticCardTextBody({
content,
bodyClass = "card-body",
}: {
content: string;
bodyClass?: string;
}) {
const html = looksLikeHtml(content) ? content : (marked.parse(content) as string);
return <div class={bodyClass} dangerouslySetInnerHTML={{ __html: html }} />;
}
export function DynamicCardBody({
item,
surface,
bodyClass = "card-body",
}: {
item: RuntimeItem;
surface: RuntimeSurface;
bodyClass?: string;
}) {
const templateKey = item.templateKey?.trim() || "";
const identity = runtimeItemId(item);
const rootRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef<MountedRuntimeCard | null>(null);
const [runtimeAvailable, setRuntimeAvailable] = useState<boolean | null>(
templateKey ? null : false,
);
const [runtimeState, setRuntimeState] = useState<Record<string, JsonValue>>(
normalizeTemplateState(item.templateState),
);
const runtimeStateRef = useRef(runtimeState);
runtimeStateRef.current = runtimeState;
const host = useRuntimeHost(surface, item, runtimeStateRef, setRuntimeState);
useEffect(() => {
const nextState = normalizeTemplateState(item.templateState);
const activeElement = document.activeElement;
const editingInside =
activeElement instanceof Node && !!rootRef.current?.contains(activeElement);
if (editingInside) return;
const currentJson = JSON.stringify(runtimeStateRef.current);
const nextJson = JSON.stringify(nextState);
if (currentJson === nextJson) return;
runtimeStateRef.current = nextState;
setRuntimeState(nextState);
}, [item.updatedAt, item.templateState]);
useEffect(() => {
let cancelled = false;
const mountRuntime = async () => {
if (!templateKey) {
setRuntimeAvailable(false);
return;
}
setRuntimeAvailable(null);
const assets = await loadRuntimeAssets(templateKey);
if (cancelled) return;
if (!assets || !rootRef.current) {
setRuntimeAvailable(false);
return;
}
mountedRef.current?.destroy?.();
rootRef.current.innerHTML = materializeRuntimeHtml(
assets.html || "",
item,
runtimeStateRef.current,
);
mountedRef.current =
assets.module.mount({
root: rootRef.current,
item,
state: runtimeStateRef.current,
host,
}) || null;
setRuntimeAvailable(true);
};
void mountRuntime();
return () => {
cancelled = true;
mountedRef.current?.destroy?.();
mountedRef.current = null;
host.setRefreshHandler(null);
host.setLiveContent(undefined);
host.clearSelection();
disposeCardRuntimeEntry(identity);
};
}, [host, identity, templateKey]);
useEffect(() => {
if (!runtimeAvailable || !rootRef.current) return;
syncRuntimeStateScript(rootRef.current, runtimeState);
if (!mountedRef.current?.update) return;
mountedRef.current.update({
root: rootRef.current,
item,
state: runtimeState,
host,
});
}, [host, item, runtimeAvailable, runtimeState]);
if (!templateKey || runtimeAvailable === false) {
return <StaticCardTextBody content={item.content} bodyClass={bodyClass} />;
}
return <div ref={rootRef} class={bodyClass} />;
}

File diff suppressed because it is too large Load diff

View file

@ -3,28 +3,6 @@ interface VoiceStatusProps {
visible: boolean;
}
function SpeakerIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
<path
d="M17 9.5a4 4 0 0 1 0 5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
<path
d="M18.8 7a7 7 0 0 1 0 10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function TextBubbleIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -38,28 +16,6 @@ function TextBubbleIcon() {
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6.5 8A7 7 0 1 1 5 12"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.9"
/>
<path
d="M6.5 4.5V8H10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.9"
/>
</svg>
);
}
export function VoiceStatus({ text, visible }: VoiceStatusProps) {
return (
<div id="voiceStatus" class={visible ? "visible" : ""}>
@ -69,54 +25,37 @@ export function VoiceStatus({ text, visible }: VoiceStatusProps) {
}
interface ControlBarProps {
onReset(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): void;
}
export function ControlBar({ onReset, textOnly, onToggleTextOnly }: ControlBarProps) {
const toggleLabel = textOnly ? "Text-only mode on" : "Voice mode on";
export function ControlBar({ textOnly, onToggleTextOnly }: ControlBarProps) {
const voiceActive = !textOnly;
const toggleLabel = voiceActive ? "Exit voice mode" : "Open voice mode";
return (
<div id="controls">
<button
id="textOnlyToggleBtn"
class={`control-switch${textOnly ? " active" : ""}`}
type="button"
role="switch"
aria-checked={textOnly}
aria-label={toggleLabel}
title={toggleLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTextOnly(!textOnly);
}}
>
<span class="control-switch-shell" aria-hidden="true">
<span class="control-switch-icon control-switch-icon-speaker">
<SpeakerIcon />
</span>
<span class="control-switch-icon control-switch-icon-text">
<TextBubbleIcon />
</span>
<span class="control-switch-thumb" />
</span>
</button>
<button
id="resetSessionBtn"
class="control-icon-btn"
type="button"
aria-label="Reset context"
title="Reset context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReset();
}}
>
<ResetIcon />
</button>
<div class="control-group">
{voiceActive ? (
<button
id="voiceModeBtn"
class="control-mode-btn active"
type="button"
aria-pressed={voiceActive}
aria-label={toggleLabel}
title={toggleLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTextOnly(!textOnly);
}}
>
<span class="control-mode-btn-icon" aria-hidden="true">
<TextBubbleIcon />
</span>
</button>
) : null}
</div>
</div>
);
}

View file

@ -1,11 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { LogLine } from "../types";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { AgentState, LogLine } from "../types";
interface Props {
lines: LogLine[];
disabled: boolean;
onSendMessage(text: string): Promise<void>;
onOpenVoiceMode?(): Promise<void> | void;
onExpandChange?(expanded: boolean): void;
contextLabel?: string | null;
onClearContext?(): void;
agentState: AgentState;
textStreaming: boolean;
fullScreen?: boolean;
}
interface LogViewProps {
@ -13,6 +19,14 @@ interface LogViewProps {
scrollRef: { current: HTMLElement | null };
}
type ChatRole = "user" | "assistant" | "system" | "tool";
interface ActivityStatus {
tone: "waiting" | "thinking" | "tool" | "replying";
label: string;
detail?: string;
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@ -21,6 +35,28 @@ function SendIcon() {
);
}
function SpeakerIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
<path
d="M17 9.5a4 4 0 0 1 0 5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
<path
d="M18.8 7a7 7 0 0 1 0 10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@ -30,15 +66,72 @@ function CloseIcon() {
}
function formatLine(line: LogLine): string {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase();
if (role === "nanobot") {
return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
if (role === "nanobot" || role === "nanobot-progress") {
return line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "");
}
if (role === "user") {
return line.text;
}
if (role === "system" || role === "wisper") {
return line.text;
}
if (role === "tool") {
return `[${time}] tool: ${line.text}`;
return `tool: ${line.text}`;
}
return `[${time}] ${line.role}: ${line.text}`;
return `${line.role}: ${line.text}`;
}
function toChatRole(line: LogLine): ChatRole {
const role = line.role.trim().toLowerCase();
if (role === "user") return "user";
if (role === "nanobot" || role === "nanobot-progress") return "assistant";
if (role === "tool") return "tool";
return "system";
}
function isEphemeralLine(line: LogLine): boolean {
const role = line.role.trim().toLowerCase();
if (role !== "system") return false;
return /^(connected|disconnected)\.?$/i.test(line.text.trim());
}
function deriveActivityStatus(
lines: LogLine[],
agentState: AgentState,
textStreaming: boolean,
sending: boolean,
): ActivityStatus | null {
if (!textStreaming && !sending && agentState === "idle") return null;
const latestUserId = [...lines].reverse().find((line) => line.role === "user")?.id ?? -1;
const latestAssistantProgress = [...lines]
.reverse()
.find((line) => line.id > latestUserId && line.role === "nanobot-progress");
const latestTool = [...lines]
.reverse()
.find((line) => line.id > latestUserId && line.role === "tool");
if (latestAssistantProgress) {
return { tone: "replying", label: "Nanobot is replying" };
}
if (latestTool && (textStreaming || sending)) {
return {
tone: "tool",
label: "Nanobot is using a tool",
detail: latestTool.text.trim() || undefined,
};
}
if (agentState === "thinking") {
return { tone: "thinking", label: "Nanobot is thinking" };
}
if (agentState === "speaking") {
return { tone: "replying", label: "Nanobot is replying" };
}
if (textStreaming || sending) {
return { tone: "waiting", label: "Waiting for Nanobot" };
}
return null;
}
function LogCompose({
@ -48,6 +141,11 @@ function LogCompose({
setText,
onClose,
onSend,
onOpenVoiceMode,
showClose,
contextLabel,
onClearContext,
activityStatus,
}: {
disabled: boolean;
sending: boolean;
@ -55,6 +153,11 @@ function LogCompose({
setText(value: string): void;
onClose(): void;
onSend(): void;
onOpenVoiceMode?: () => void;
showClose: boolean;
contextLabel?: string | null;
onClearContext?: () => void;
activityStatus?: ActivityStatus | null;
}) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
@ -72,27 +175,69 @@ function LogCompose({
return (
<div id="log-compose">
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
{showClose ? (
<div id="log-compose-toolbar">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
) : null}
<div id="log-compose-field">
{activityStatus ? (
<div id="log-compose-status" data-tone={activityStatus.tone} aria-live="polite">
<span id="log-compose-status-dots" aria-hidden="true">
<span class="log-compose-status-dot" />
<span class="log-compose-status-dot" />
<span class="log-compose-status-dot" />
</span>
<span id="log-compose-status-label">{activityStatus.label}</span>
{activityStatus.detail ? (
<span id="log-compose-status-detail">{activityStatus.detail}</span>
) : null}
</div>
) : null}
{contextLabel ? (
<div id="log-compose-context">
<button
id="log-compose-context-chip"
type="button"
onClick={() => onClearContext?.()}
disabled={disabled || sending}
>
[{contextLabel}]
</button>
</div>
) : null}
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
{onOpenVoiceMode ? (
<button
id="log-voice-btn"
type="button"
aria-label="Open voice mode"
disabled={disabled || sending}
onClick={onOpenVoiceMode}
>
<SpeakerIcon />
</button>
) : null}
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
</div>
);
@ -117,6 +262,45 @@ function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
);
}
function ChatLogView({ lines, scrollRef }: LogViewProps) {
const visibleLines = lines.filter((line) => !isEphemeralLine(line));
return (
<div
id="chat-scroll"
ref={(node) => {
scrollRef.current = node;
}}
>
{visibleLines.length === 0 ? (
<div id="chat-empty">
<div id="chat-empty-eyebrow">Nanobot</div>
<h1 id="chat-empty-title">Start a conversation</h1>
<p id="chat-empty-copy">
Ask a question, plan something, or switch to voice when you want the live interface.
</p>
</div>
) : (
<div id="chat-inner">
{visibleLines.map((line) => {
const chatRole = toChatRole(line);
return (
<div key={line.id} class={`chat-row ${chatRole}`}>
<div class={`chat-bubble ${chatRole}`}>
{chatRole === "user" && line.contextLabel ? (
<span class="chat-context-inline">[{line.contextLabel}] </span>
) : null}
{formatLine(line)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
return (
<button
@ -139,23 +323,46 @@ function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExp
);
}
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
export function LogPanel({
lines,
disabled,
onSendMessage,
onOpenVoiceMode,
onExpandChange,
contextLabel,
onClearContext,
agentState,
textStreaming,
fullScreen = false,
}: Props) {
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const isExpanded = fullScreen || expanded;
const activityStatus = useMemo(
() => (fullScreen ? deriveActivityStatus(lines, agentState, textStreaming, sending) : null),
[agentState, fullScreen, lines, sending, textStreaming],
);
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
useEffect(
() => onExpandChange?.(fullScreen ? false : isExpanded),
[fullScreen, isExpanded, onExpandChange],
);
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
useEffect(() => {
if (fullScreen) setExpanded(true);
}, [fullScreen]);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, expanded]);
}, [lines, isExpanded]);
const collapse = useCallback(() => {
if (fullScreen) return;
setExpanded(false);
setText("");
}, []);
}, [fullScreen]);
const expand = useCallback(() => {
if (!expanded) setExpanded(true);
@ -176,13 +383,19 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
}, [disabled, onSendMessage, sending, text]);
return (
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
{expanded ? (
<div
id="log"
class={`${isExpanded ? "expanded" : ""}${fullScreen ? " chat-mode" : ""}`}
data-no-swipe={fullScreen ? undefined : "1"}
>
{fullScreen ? (
<ChatLogView lines={lines} scrollRef={scrollRef} />
) : isExpanded ? (
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
) : (
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
)}
{expanded && (
{isExpanded && (
<LogCompose
disabled={disabled}
sending={sending}
@ -192,6 +405,11 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
onSend={() => {
void send();
}}
onOpenVoiceMode={fullScreen ? () => void onOpenVoiceMode?.() : undefined}
showClose={!fullScreen}
activityStatus={activityStatus}
contextLabel={contextLabel}
onClearContext={onClearContext}
/>
)}
</div>

View file

@ -0,0 +1,514 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { ThemeName, ThemeOption } from "../theme/themes";
import type { SessionSummary } from "../types";
const DRAWER_SWIPE_INTENT_PX = 12;
const DRAWER_SWIPE_CLOSE_THRESHOLD_PX = 52;
const DRAWER_SWIPE_DIRECTION_RATIO = 1.15;
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
const SESSION_DRAWER_GUTTER_PX = 28;
const SESSION_LONG_PRESS_MS = 460;
const SESSION_LONG_PRESS_MOVE_PX = 14;
interface DrawerSwipeState {
identifier: number;
x: number;
y: number;
}
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 6L18 18M18 6L6 18"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.9"
/>
</svg>
);
}
function formatUpdatedAt(timestamp: string): string {
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function findTouchById(touches: TouchList, identifier: number): Touch | null {
for (let i = 0; i < touches.length; i += 1) {
const touch = touches.item(i);
if (touch && touch.identifier === identifier) return touch;
}
return null;
}
function hasSwipeIntent(dx: number, dy: number): boolean {
return Math.abs(dx) >= DRAWER_SWIPE_INTENT_PX || Math.abs(dy) >= DRAWER_SWIPE_INTENT_PX;
}
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
return Math.abs(dx) < Math.abs(dy) * DRAWER_SWIPE_DIRECTION_RATIO;
}
function getDrawerWidth(): number {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 1;
return Math.max(
1,
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
);
}
function useSessionDrawerCloseSwipe(open: boolean, onClose: () => void) {
const [swipe, setSwipe] = useState<DrawerSwipeState | null>(null);
const [closeProgress, setCloseProgress] = useState(0);
const clearSwipe = useCallback(() => {
setSwipe(null);
setCloseProgress(0);
}, []);
useEffect(() => {
if (!open) clearSwipe();
}, [clearSwipe, open]);
const onTouchStart = useCallback(
(e: Event) => {
if (!open) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
setSwipe({
identifier: touch.identifier,
x: touch.clientX,
y: touch.clientY,
});
},
[open],
);
const onTouchMove = useCallback(
(e: Event) => {
if (!open || !swipe) return;
const te = e as TouchEvent;
const touch = findTouchById(te.touches, swipe.identifier);
if (!touch) return;
const dx = swipe.x - touch.clientX;
const dy = touch.clientY - swipe.y;
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) {
clearSwipe();
return;
}
if (te.cancelable) te.preventDefault();
setCloseProgress(Math.max(0, Math.min(1, dx / getDrawerWidth())));
},
[clearSwipe, open, swipe],
);
const onTouchEnd = useCallback(
(e: Event) => {
if (!swipe) {
clearSwipe();
return;
}
const te = e as TouchEvent;
const touch = findTouchById(te.changedTouches, swipe.identifier);
if (!touch) {
clearSwipe();
return;
}
const dx = swipe.x - touch.clientX;
const shouldClose = dx >= DRAWER_SWIPE_CLOSE_THRESHOLD_PX;
clearSwipe();
if (shouldClose) onClose();
},
[clearSwipe, onClose, swipe],
);
return {
closeProgress,
dragging: closeProgress > 0,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clearSwipe,
};
}
interface SessionDrawerProps {
open: boolean;
progress: number;
dragging: boolean;
sessions: SessionSummary[];
activeSessionId: string;
busy: boolean;
onClose(): void;
onCreate(): Promise<void> | void;
onSelect(chatId: string): Promise<void> | void;
onRename(chatId: string, title: string): Promise<void> | void;
onDelete(chatId: string): Promise<void> | void;
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}
function useMetaOverflow() {
const ref = useRef<HTMLDivElement | null>(null);
const [compact, setCompact] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
let frame = 0;
const measure = () => {
frame = 0;
setCompact((current) => {
const tooWide = node.scrollWidth > node.clientWidth + 1;
if (!current) return tooWide;
// Hysteresis: once compact, require real spare room before switching back.
const hasSpareRoom = node.scrollWidth <= node.clientWidth - 12;
return !hasSpareRoom;
});
};
const scheduleMeasure = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
scheduleMeasure();
const observer = new ResizeObserver(scheduleMeasure);
observer.observe(node);
return () => {
if (frame) cancelAnimationFrame(frame);
observer.disconnect();
};
}, []);
return { ref, compact };
}
function SessionDrawerItem({
session,
active,
busy,
onSelect,
onOpenMenu,
}: {
session: SessionSummary;
active: boolean;
busy: boolean;
onSelect(chatId: string): Promise<void> | void;
onOpenMenu(session: SessionSummary): void;
}) {
const meta = useMetaOverflow();
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdStartRef = useRef<{ x: number; y: number } | null>(null);
const longPressTriggeredRef = useRef(false);
const clearHoldTimer = useCallback(() => {
if (holdTimerRef.current) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
holdStartRef.current = null;
}, []);
const scheduleLongPress = useCallback(
(x: number, y: number) => {
if (busy) return;
longPressTriggeredRef.current = false;
holdStartRef.current = { x, y };
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
holdTimerRef.current = setTimeout(() => {
holdTimerRef.current = null;
longPressTriggeredRef.current = true;
onOpenMenu(session);
}, SESSION_LONG_PRESS_MS);
},
[busy, onOpenMenu, session],
);
useEffect(() => clearHoldTimer, [clearHoldTimer]);
return (
<button
class={`session-drawer-item${active ? " active" : ""}`}
type="button"
aria-disabled={busy ? "true" : undefined}
onClick={(e) => {
if (longPressTriggeredRef.current) {
e.preventDefault();
e.stopPropagation();
longPressTriggeredRef.current = false;
return;
}
if (busy || active) return;
void onSelect(session.chat_id);
}}
onContextMenu={(e) => {
e.preventDefault();
if (busy) return;
longPressTriggeredRef.current = true;
onOpenMenu(session);
}}
onTouchStart={(e) => {
if (e.touches.length !== 1) return;
const touch = e.touches.item(0);
if (!touch) return;
scheduleLongPress(touch.clientX, touch.clientY);
}}
onTouchMove={(e) => {
const touch = e.touches.item(0);
const start = holdStartRef.current;
if (!touch || !start) return;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (Math.hypot(dx, dy) > SESSION_LONG_PRESS_MOVE_PX) clearHoldTimer();
}}
onTouchEnd={() => {
clearHoldTimer();
}}
onTouchCancel={() => {
clearHoldTimer();
}}
>
<div class="session-drawer-item-title">{session.title || "New conversation"}</div>
{session.preview ? <div class="session-drawer-item-preview">{session.preview}</div> : null}
<div ref={meta.ref} class={`session-drawer-item-meta${meta.compact ? " compact" : ""}`}>
<span>{formatUpdatedAt(session.updated_at)}</span>
<span>{session.message_count} msgs</span>
</div>
</button>
);
}
function SessionActionMenu({
session,
busy,
onClose,
onRename,
onDelete,
}: {
session: SessionSummary;
busy: boolean;
onClose(): void;
onRename(chatId: string, title: string): Promise<void> | void;
onDelete(chatId: string): Promise<void> | void;
}) {
return (
<>
<button
id="session-action-backdrop"
type="button"
aria-label="Close conversation actions"
onClick={onClose}
/>
<div id="session-action-sheet" data-no-swipe="1">
<div id="session-action-title">{session.title || "New conversation"}</div>
<button
class="session-action-btn"
type="button"
disabled={busy}
onClick={() => {
const nextTitle = window.prompt("Rename conversation", session.title || "");
if (nextTitle === null) return;
void onRename(session.chat_id, nextTitle.trim());
onClose();
}}
>
Rename
</button>
<button
class="session-action-btn destructive"
type="button"
disabled={busy}
onClick={() => {
const confirmed = window.confirm(`Delete "${session.title || "New conversation"}"?`);
if (!confirmed) return;
void onDelete(session.chat_id);
onClose();
}}
>
Delete
</button>
<button class="session-action-btn" type="button" onClick={onClose}>
Cancel
</button>
</div>
</>
);
}
function SessionThemePicker({
activeTheme,
themeOptions,
onSelectTheme,
}: {
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}) {
return (
<div id="session-drawer-theme">
<div id="session-drawer-theme-label">Theme</div>
<div id="session-drawer-theme-options">
{themeOptions.map((theme) => (
<button
key={theme.name}
type="button"
class={`session-drawer-theme-option${theme.name === activeTheme ? " active" : ""}`}
aria-pressed={theme.name === activeTheme}
onClick={() => onSelectTheme(theme.name)}
>
<span
class="session-drawer-theme-swatch"
aria-hidden="true"
style={{ background: theme.swatch }}
/>
<span>{theme.label}</span>
</button>
))}
</div>
</div>
);
}
export function SessionDrawer({
open,
progress,
dragging,
sessions,
activeSessionId,
busy,
onClose,
onCreate,
onSelect,
onRename,
onDelete,
activeTheme,
themeOptions,
onSelectTheme,
}: SessionDrawerProps) {
const closeSwipe = useSessionDrawerCloseSwipe(open, onClose);
const [actionSession, setActionSession] = useState<SessionSummary | null>(null);
useEffect(() => {
if (!open) setActionSession(null);
}, [open]);
useEffect(() => {
setActionSession((current) => {
if (!current) return current;
return current.chat_id === activeSessionId ? current : null;
});
}, [activeSessionId]);
const visible = open || progress > 0 || closeSwipe.closeProgress > 0;
if (!visible) return null;
const drawerProgress = open ? 1 - closeSwipe.closeProgress : progress;
const inMotion = dragging || closeSwipe.dragging;
const backdropStyle = {
opacity: String(drawerProgress),
transition: inMotion ? "none" : "opacity 0.2s ease",
pointerEvents: open ? "auto" : "none",
};
const drawerStyle = {
transform: `translateX(${(drawerProgress - 1) * 100}%)`,
transition: inMotion ? "none" : "transform 0.2s ease",
};
const actionMenuOpen = actionSession !== null;
return (
<div id="session-drawer-root" data-no-swipe="1">
<button
id="session-drawer-backdrop"
type="button"
aria-label="Close conversations"
style={backdropStyle}
onClick={onClose}
/>
<aside
id="session-drawer"
aria-label="Conversations"
style={drawerStyle}
onTouchStart={closeSwipe.onTouchStart}
onTouchMove={closeSwipe.onTouchMove}
onTouchEnd={closeSwipe.onTouchEnd}
onTouchCancel={closeSwipe.onTouchCancel}
>
<div id="session-drawer-header">
<div>
<div id="session-drawer-eyebrow">Nanobot</div>
<h2 id="session-drawer-title">Conversations</h2>
</div>
<button
id="session-drawer-close"
type="button"
aria-label="Close conversations"
onClick={onClose}
>
<CloseIcon />
</button>
</div>
<button
id="session-drawer-new"
type="button"
disabled={busy}
onClick={() => {
void onCreate();
}}
>
New conversation
</button>
<SessionThemePicker
activeTheme={activeTheme}
themeOptions={themeOptions}
onSelectTheme={onSelectTheme}
/>
<div id="session-drawer-list">
{sessions.map((session) => {
const active = session.chat_id === activeSessionId;
return (
<SessionDrawerItem
key={session.chat_id}
session={session}
active={active}
busy={busy}
onSelect={onSelect}
onOpenMenu={setActionSession}
/>
);
})}
</div>
</aside>
{actionMenuOpen ? (
<SessionActionMenu
session={actionSession}
busy={busy}
onClose={() => setActionSession(null)}
onRename={onRename}
onDelete={onDelete}
/>
) : null}
</div>
);
}

View file

@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import type { WorkbenchItem } from "../types";
import { DynamicCardBody } from "./CardBodyRenderer";
interface WorkbenchOverlayProps {
items: WorkbenchItem[];
onDismiss(id: string): Promise<void>;
onPromote(id: string): Promise<void>;
}
function DismissIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M14.5 5.5L5.5 14.5M5.5 5.5L14.5 14.5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function PromoteIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 4l4 4h-2.4v5H8.4V8H6l4-4Z" fill="currentColor" />
<path
d="M5.5 15.5h9"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function workbenchTabLabel(item: WorkbenchItem): string {
const title = item.title.trim();
if (!title) return "Workbench";
return title.length > 22 ? `${title.slice(0, 22).trimEnd()}...` : title;
}
function WorkbenchQuestionBody({ item }: { item: WorkbenchItem }) {
return (
<div class="workbench-overlay-question">
{item.content.trim() ? (
<div class="workbench-overlay-question-copy">{item.content.trim()}</div>
) : null}
{item.question?.trim() ? (
<div class="workbench-overlay-question-prompt">{item.question.trim()}</div>
) : null}
{item.choices?.length ? (
<div class="workbench-overlay-question-choices">
{item.choices.map((choice) => (
<span key={choice} class="workbench-overlay-question-choice">
{choice}
</span>
))}
</div>
) : null}
</div>
);
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this overlay owns item selection and async promote/dismiss controls together
export function WorkbenchOverlay({ items, onDismiss, onPromote }: WorkbenchOverlayProps) {
const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);
const [busyId, setBusyId] = useState<string | null>(null);
const [errorText, setErrorText] = useState("");
useEffect(() => {
if (!items.length) {
setActiveId(null);
return;
}
if (activeId && items.some((item) => item.id === activeId)) return;
setActiveId(items[0].id);
}, [activeId, items]);
const activeItem = useMemo(
() =>
activeId
? (items.find((item) => item.id === activeId) ?? items[0] ?? null)
: (items[0] ?? null),
[activeId, items],
);
if (!activeItem) return null;
const busy = busyId === activeItem.id;
const handleDismiss = async () => {
if (!activeItem || busy) return;
setBusyId(activeItem.id);
setErrorText("");
try {
await onDismiss(activeItem.id);
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyId(null);
}
};
const handlePromote = async () => {
if (!activeItem || busy || !activeItem.promotable) return;
setBusyId(activeItem.id);
setErrorText("");
try {
await onPromote(activeItem.id);
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyId(null);
}
};
return (
<div id="workbench-overlay-root" data-no-swipe="1">
<section id="workbench-overlay" aria-label="Workbench">
<div id="workbench-overlay-head">
<div id="workbench-overlay-eyebrow">Workbench</div>
<div id="workbench-overlay-actions">
{activeItem.promotable ? (
<button
class="workbench-overlay-icon-btn"
type="button"
aria-label="Add this workbench item to the feed"
title="Add to feed"
disabled={busy}
onClick={handlePromote}
>
<PromoteIcon />
</button>
) : null}
<button
class="workbench-overlay-icon-btn"
type="button"
aria-label="Dismiss this workbench item"
title="Dismiss"
disabled={busy}
onClick={handleDismiss}
>
<DismissIcon />
</button>
</div>
</div>
{items.length > 1 ? (
<div id="workbench-overlay-tabs">
{items.map((item) => (
<button
key={item.id}
class={`workbench-overlay-tab${item.id === activeItem.id ? " active" : ""}`}
type="button"
disabled={busyId !== null && busyId !== item.id}
onClick={() => {
setActiveId(item.id);
setErrorText("");
}}
>
{workbenchTabLabel(item)}
</button>
))}
</div>
) : null}
<div id="workbench-overlay-title">{activeItem.title || "Untitled workbench"}</div>
{activeItem.contextSummary?.trim() ? (
<div id="workbench-overlay-blurb">{activeItem.contextSummary.trim()}</div>
) : null}
<div id="workbench-overlay-body">
{activeItem.kind === "question" ? (
<WorkbenchQuestionBody item={activeItem} />
) : (
<DynamicCardBody item={activeItem} surface="workbench" />
)}
</div>
<div id="workbench-overlay-footer">
{busy ? (
<div class="workbench-overlay-status">Saving to workbench...</div>
) : errorText ? (
<div class="workbench-overlay-status error">{errorText}</div>
) : activeItem.promotable ? (
<div class="workbench-overlay-status">Add to feed when it is worth keeping.</div>
) : (
<div class="workbench-overlay-status">Temporary session artifact.</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,399 @@
import { useEffect, useState } from "preact/hooks";
import { decodeJsonError } from "../../cardRuntime/api";
import { requestCardFeedRefresh } from "../../cardRuntime/store";
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
interface InboxItem {
path: string;
title: string;
kind: string;
status: string;
source: string;
confidence: number | null;
captured: string;
updated: string;
suggestedDue: string;
tags: string[];
body: string;
}
function normalizeTaskTag(raw: string): string {
const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTaskTags(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const value of raw) {
const tag = normalizeTaskTag(String(value || ""));
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeInboxItem(raw: unknown): InboxItem | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path.trim() : "";
if (!path) return null;
return {
path,
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: "Untitled capture",
kind: typeof record.kind === "string" ? record.kind.trim() : "unknown",
status: typeof record.status === "string" ? record.status.trim() : "new",
source: typeof record.source === "string" ? record.source.trim() : "unknown",
confidence:
typeof record.confidence === "number" && Number.isFinite(record.confidence)
? record.confidence
: null,
captured: typeof record.captured === "string" ? record.captured.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
suggestedDue: typeof record.suggested_due === "string" ? record.suggested_due.trim() : "",
tags: normalizeTaskTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
};
}
async function fetchInboxItems(limit = 4): Promise<InboxItem[]> {
const params = new URLSearchParams({ limit: String(limit) });
const resp = await fetch(`/inbox?${params.toString()}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { items?: unknown };
const items = Array.isArray(payload.items) ? payload.items : [];
return items.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null);
}
function requestInboxRefresh(): void {
window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
}
function extractCaptureTags(text: string): string[] {
const matches = text.match(/(^|\s)#([A-Za-z0-9_-]+)/g) ?? [];
return Array.from(
new Set(
matches
.map((match) => match.trim())
.filter(Boolean)
.map((tag) => normalizeTaskTag(tag)),
),
);
}
export async function captureInboxItem(text: string): Promise<InboxItem> {
const trimmed = text.trim();
if (!trimmed) throw new Error("capture text is required");
const resp = await fetch("/inbox/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: trimmed,
tags: extractCaptureTags(trimmed),
source: "web-ui",
}),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { item?: unknown };
const item = normalizeInboxItem(payload.item);
if (!item) throw new Error("invalid inbox response");
requestInboxRefresh();
return item;
}
async function dismissInboxItem(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
}
async function acceptInboxItemAsTask(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/accept-task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath, lane: "backlog" }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
requestCardFeedRefresh();
}
function formatInboxUpdatedAt(timestamp: string): string {
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function summarizeInboxBody(body: string): string {
return body.replace(/\r\n?/g, "\n").split("\n## Raw Capture")[0].replace(/\s+/g, " ").trim();
}
function computeInboxScore(items: InboxItem[]): number {
if (!items.length) return 0;
const newest = items[0];
const newestMs = newest.updated ? new Date(newest.updated).getTime() : Number.NaN;
const ageHours = Number.isFinite(newestMs) ? (Date.now() - newestMs) / (60 * 60 * 1000) : 999;
if (ageHours <= 1) return 91;
if (ageHours <= 6) return 86;
if (ageHours <= 24) return 82;
return 78;
}
export function InboxQuickAdd({
onSubmit,
visible,
}: {
onSubmit(text: string): Promise<void>;
visible: boolean;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!visible) {
setOpen(false);
setError("");
}
}, [visible]);
const close = () => {
if (busy) return;
setOpen(false);
setError("");
};
const submit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
setBusy(true);
setError("");
try {
await onSubmit(trimmed);
setValue("");
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (!visible) return null;
return (
<>
<button
id="inbox-quick-add-btn"
type="button"
data-no-swipe="1"
aria-label="Quick add to inbox"
onClick={() => setOpen(true)}
>
+
</button>
{open ? (
<>
<button
id="inbox-quick-add-backdrop"
type="button"
data-no-swipe="1"
aria-label="Close inbox quick add"
onClick={close}
/>
<div id="inbox-quick-add-sheet" data-no-swipe="1">
<div class="inbox-quick-add-sheet__title">Quick Add</div>
<textarea
class="inbox-quick-add-sheet__input"
rows={3}
placeholder="Something to remember..."
value={value}
disabled={busy}
onInput={(event) => setValue(event.currentTarget.value)}
/>
{error ? <div class="inbox-quick-add-sheet__error">{error}</div> : null}
<div class="inbox-quick-add-sheet__actions">
<button
class="inbox-quick-add-sheet__btn"
type="button"
disabled={busy}
onClick={close}
>
Cancel
</button>
<button
class="inbox-quick-add-sheet__btn primary"
type="button"
disabled={busy || !value.trim()}
onClick={() => {
void submit();
}}
>
Add
</button>
</div>
</div>
</>
) : null}
</>
);
}
export function InboxReviewCard() {
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [busyItem, setBusyItem] = useState<{ path: string; action: "accept" | "dismiss" } | null>(
null,
);
const [errorText, setErrorText] = useState("");
const loadInbox = async () => {
setLoading(true);
try {
const nextItems = await fetchInboxItems(4);
setItems(nextItems);
setErrorText("");
} catch (error) {
console.error("Inbox load failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadInbox();
const onRefresh = (event?: Event) => {
const customEvent = event as CustomEvent<{ items?: unknown[] }> | undefined;
const nextItems = customEvent?.detail?.items;
if (Array.isArray(nextItems)) {
setItems(
nextItems.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null),
);
setLoading(false);
setErrorText("");
return;
}
void loadInbox();
};
window.addEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
window.removeEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, []);
const withBusyItem = async (
itemPath: string,
actionKind: "accept" | "dismiss",
action: () => Promise<void>,
) => {
setBusyItem({ path: itemPath, action: actionKind });
setErrorText("");
try {
await action();
await loadInbox();
} catch (error) {
console.error("Inbox action failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyItem(null);
}
};
const score = computeInboxScore(items);
if (!loading && !errorText && items.length === 0) {
return null;
}
return (
<article class="card kind-text state-active inbox-review-card">
<header class="card-header">
<div class="card-title-wrap">
<div class="card-title-line">
<span class="card-title">Inbox</span>
</div>
<div class="card-meta">
<span class="card-state state-active">
{loading ? "Loading" : `${items.length} open`}
</span>
</div>
</div>
</header>
<div class="inbox-review-card__body">
<div class="inbox-review-card__summary">
Capture first, organize later.
{score ? <span class="inbox-review-card__score">Priority {score}</span> : null}
</div>
{errorText ? <div class="inbox-review-card__error">{errorText}</div> : null}
{items.map((item) => {
const preview = summarizeInboxBody(item.body);
const itemBusy = busyItem?.path === item.path;
const accepting = busyItem?.path === item.path && busyItem.action === "accept";
return (
<div key={item.path} class="inbox-review-card__item">
<div class="inbox-review-card__item-topline">
<span class="inbox-review-card__item-kind">{item.kind}</span>
<span class={`inbox-review-card__item-updated${accepting ? " is-active" : ""}`}>
{accepting ? "Nanobot..." : formatInboxUpdatedAt(item.updated || item.captured)}
</span>
</div>
<div class="inbox-review-card__item-title">{item.title}</div>
{preview ? <div class="inbox-review-card__item-preview">{preview}</div> : null}
{item.tags.length ? (
<div class="inbox-review-card__tags">
{item.tags.map((tag) => (
<span key={tag} class="inbox-review-card__tag">
{tag}
</span>
))}
</div>
) : null}
<div class="inbox-review-card__actions">
<button
class="inbox-review-card__action"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "accept", () => acceptInboxItemAsTask(item.path));
}}
>
{accepting ? "Nanobot..." : "Ask Nanobot"}
</button>
<button
class="inbox-review-card__action ghost"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "dismiss", () => dismissInboxItem(item.path));
}}
>
Dismiss
</button>
</div>
</div>
);
})}
</div>
</article>
);
}

View file

@ -0,0 +1,19 @@
import { decodeJsonError } from "../../cardRuntime/api";
function nextLocalMidnightIso(): string {
const tomorrow = new Date();
tomorrow.setHours(24, 0, 0, 0);
return tomorrow.toISOString();
}
export async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise<void> {
const key = (cardId || "").trim();
if (!key) throw new Error("card id is required");
const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ until: nextLocalMidnightIso() }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
window.dispatchEvent(new Event("nanobot:cards-refresh"));
}

View file

@ -1,735 +1,254 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardState,
ClientMessage,
LogLine,
ServerMessage,
} from "../types";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
let cardIdCounter = 0;
let logIdCounter = 0;
const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
interface WebRTCState {
connected: boolean;
connecting: boolean;
textOnly: boolean;
agentState: AgentState;
logLines: LogLine[];
cards: CardItem[];
voiceStatus: string;
statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
setTextOnly(enabled: boolean): void;
connect(): Promise<void>;
}
type AppendLine = (role: string, text: string, timestamp: string) => void;
type UpsertCard = (item: Omit<CardItem, "id">) => void;
type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
type RawPersistedCard =
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
localTracksRef: { current: MediaStreamTrack[] };
}
interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
onDcOpen: () => void;
closePC: () => void;
}
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
if (!card.serverId) return card.priority;
const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, unknown>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
function compareCards(a: CardItem, b: CardItem): number {
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
return b.createdAt.localeCompare(a.createdAt);
}
function sortCards(items: CardItem[]): CardItem[] {
return [...items].sort(compareCards);
}
function stopMediaTracks(tracks: Iterable<MediaStreamTrack | null | undefined>): void {
const seen = new Set<MediaStreamTrack>();
for (const track of tracks) {
if (!track || seen.has(track)) continue;
seen.add(track);
track.stop();
}
}
function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function appendLogLineEntry(
prev: LogLine[],
role: string,
text: string,
timestamp: string,
): LogLine[] {
const next = [
...prev,
{ id: logIdCounter++, role, text, timestamp: timestamp || new Date().toISOString() },
];
return next.length > 250 ? next.slice(next.length - 250) : next;
}
function mergeCardItem(prev: CardItem[], item: Omit<CardItem, "id">): CardItem[] {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
}
function normalizePersistedCard(raw: RawPersistedCard): Omit<CardItem, "id"> {
return toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
}
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = normalizePersistedCard(raw);
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
setAgentState: SetAgentState,
appendLine: AppendLine,
upsertCard: UpsertCard,
idleFallback: IdleFallbackControls,
): void {
if (msg.type === "agent_state") {
idleFallback.clear();
setAgentState(() => msg.state);
return;
}
if (msg.type === "message") {
if (msg.is_tool_hint) {
appendLine("tool", msg.content, msg.timestamp);
return;
}
if (!msg.is_progress) {
appendLine(msg.role, msg.content, msg.timestamp);
idleFallback.schedule();
}
return;
}
if (msg.type === "card") {
upsertCard(toCardItem(msg));
idleFallback.schedule();
return;
}
if (msg.type === "error") {
appendLine("system", msg.error, "");
idleFallback.schedule();
}
}
async function acquireMicStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
},
video: false,
});
} catch {
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
}
}
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
return new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") {
resolve();
return;
}
const check = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(() => {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
});
}
async function exchangeSdp(
localDesc: RTCSessionDescription,
): Promise<{ sdp: string; rtcType: string }> {
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
const resp = await fetch(rtcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
});
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
}
async function runConnect(
refs: RTCRefs,
cbs: RTCCallbacks,
opts: { textOnly: boolean },
): Promise<void> {
if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
return;
}
cbs.setConnecting(true);
cbs.showStatus("Connecting...");
let micStream: MediaStream | null = null;
try {
refs.localTracksRef.current = [];
if (!opts.textOnly) {
micStream = await acquireMicStream();
const audioTracks = micStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = false;
});
refs.localTracksRef.current = audioTracks;
}
// Local-only deployments do not need a public STUN server; host candidates are enough
// and avoiding external ICE gathering removes several seconds from startup latency.
const pc = new RTCPeerConnection(
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
);
refs.pcRef.current = pc;
const newRemoteStream = new MediaStream();
cbs.setRemoteStream(newRemoteStream);
if (refs.remoteAudioRef.current) {
refs.remoteAudioRef.current.srcObject = newRemoteStream;
refs.remoteAudioRef.current.play().catch(() => {});
}
pc.ontrack = (event) => {
if (event.track.kind !== "audio") return;
newRemoteStream.addTrack(event.track);
refs.remoteAudioRef.current?.play().catch(() => {});
};
const dc = pc.createDataChannel("app", { ordered: true });
refs.dcRef.current = dc;
dc.onopen = () => {
cbs.setConnected(true);
cbs.setConnecting(false);
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
};
dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
cbs.closePC();
};
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
refs.micSendersRef.current = [];
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceComplete(pc);
const localDesc = pc.localDescription;
if (!localDesc) throw new Error("No local description after ICE gathering");
const answer = await exchangeSdp(localDesc);
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
} catch (err) {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
}
}
function useBackendActions() {
const sendTextMessage = useCallback(async (text: string, metadata?: CardMessageMetadata) => {
const message = text.trim();
if (!message) return;
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message, metadata: metadata ?? {} }),
});
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
}, []);
return { sendTextMessage };
}
function useCardPolling(loadPersistedCards: () => Promise<void>) {
useEffect(() => {
loadPersistedCards().catch(() => {});
const pollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
}, 10000);
const onVisible = () => {
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
};
const onCardsRefresh = () => {
loadPersistedCards().catch(() => {});
};
window.addEventListener("focus", onVisible);
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(pollId);
window.removeEventListener("focus", onVisible);
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
document.removeEventListener("visibilitychange", onVisible);
};
}, [loadPersistedCards]);
}
function useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef,
micSendersRef,
dcRef,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
showStatus: (text: string, persistMs?: number) => void;
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
dcRef: { current: RTCDataChannel | null };
textOnlyRef: { current: boolean };
}) {
useEffect(() => {
textOnlyRef.current = textOnly;
}, [textOnly, textOnlyRef]);
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
audio.muted = textOnlyRef.current;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
}, [remoteAudioRef, textOnlyRef]);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, [micSendersRef, textOnlyRef]);
useEffect(() => {
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = textOnly;
if (textOnly) remoteAudioRef.current.pause();
else remoteAudioRef.current.play().catch(() => {});
}
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = false;
});
if (textOnly) {
const dc = dcRef.current;
if (dc?.readyState === "open") {
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
}
}
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (!idleTimerRef.current) return;
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}, []);
const schedule = useCallback(
(delayMs = 450) => {
clear();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clear, setAgentState],
);
useEffect(() => clear, [clear]);
return { clear, schedule };
}
function useLogState() {
const [logLines, setLogLines] = useState<LogLine[]>([]);
const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp));
}, []);
return { logLines, appendLine };
}
async function fetchPersistedCardsFromBackend(): Promise<RawPersistedCard[] | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/cards` : "/cards";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return null;
}
return (await resp.json()) as RawPersistedCard[];
}
function useCardsState() {
const [cards, setCards] = useState<CardItem[]>([]);
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => mergeCardItem(prev, item));
}, []);
const dismissCard = useCallback((id: number) => {
setCards((prev) => {
const card = prev.find((entry) => entry.id === id);
if (card?.serverId) {
const url = BACKEND_URL
? `${BACKEND_URL}/cards/${card.serverId}`
: `/cards/${card.serverId}`;
fetch(url, { method: "DELETE" }).catch(() => {});
}
return prev.filter((entry) => entry.id !== id);
});
}, []);
const loadPersistedCards = useCallback(async () => {
try {
const rawCards = await fetchPersistedCardsFromBackend();
if (!rawCards) return;
setCards((prev) => reconcilePersistedCards(prev, rawCards));
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, []);
useEffect(() => {
const onCardLiveContentChange = (event: Event) => {
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
const cardId =
customEvent.detail && typeof customEvent.detail.cardId === "string"
? customEvent.detail.cardId
: "";
setCards((prev) => {
if (prev.length === 0) return prev;
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
return sortCards([...prev]);
});
};
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
return () => {
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
};
}, []);
return { cards, upsertCard, dismissCard, loadPersistedCards };
}
function parseServerMessage(raw: string): Extract<ServerMessage, { type: string }> | null {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return null;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return null;
return msg as Extract<ServerMessage, { type: string }>;
}
function useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
}: {
setAgentState: SetAgentState;
appendLine: AppendLine;
upsertCard: UpsertCard;
idleFallback: IdleFallbackControls;
}) {
return useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
handleTypedMessage(msg, setAgentState, appendLine, upsertCard, idleFallback);
},
[appendLine, idleFallback, setAgentState, upsertCard],
);
}
function useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle");
const { logLines, appendLine } = useLogState();
const { cards, upsertCard, dismissCard, loadPersistedCards } = useCardsState();
const idleFallback = useIdleFallback(setAgentState);
const onDcMessage = useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
});
return { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage };
}
function usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
loadPersistedCards: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
refs: RTCRefs;
setConnected: (value: boolean) => void;
setConnecting: (value: boolean) => void;
setRemoteStream: (stream: MediaStream | null) => void;
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
const dc = refs.dcRef.current;
const pc = refs.pcRef.current;
const localTracks = refs.localTracksRef.current;
const senderTracks = refs.micSendersRef.current.map((sender) => sender.track);
refs.dcRef.current = null;
refs.pcRef.current = null;
refs.micSendersRef.current = [];
refs.localTracksRef.current = [];
stopMediaTracks([...senderTracks, ...localTracks]);
dc?.close();
pc?.close();
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const closePCRef = useRef(closePC);
useEffect(() => {
closePCRef.current = closePC;
}, [closePC]);
const connect = useCallback(async () => {
await runConnect(
refs,
{
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
},
closePC,
},
{ textOnly: textOnlyRef.current },
);
}, [
appendLine,
closePC,
loadPersistedCards,
onDcMessage,
refs,
setConnected,
setConnecting,
setRemoteStream,
showStatus,
textOnlyRef,
]);
useEffect(() => {
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
closePC();
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
useEffect(() => {
return () => {
closePCRef.current();
};
}, []);
return { closePC, connect };
}
import type { CardMessageMetadata, ClientMessage, SessionSummary } from "../types";
import {
createSessionInBackend,
deleteSessionInBackend,
fetchSessionDetailFromBackend,
fetchSessionsFromBackend,
renameSessionInBackend,
useBackendActions,
} from "./webrtc/backend";
import { useSessionSurfaceEvents } from "./webrtc/cards";
import { useMessageState } from "./webrtc/messages";
import {
usePeerConnectionControls,
useRemoteAudioBindings,
useRtcRefs,
} from "./webrtc/rtcTransport";
import type { WebRTCState } from "./webrtc/types";
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this hook owns the app's transport/session state wiring
export function useWebRTC(): WebRTCState {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [textOnly, setTextOnlyState] = useState(false);
const [textOnly, setTextOnlyState] = useState(true);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [activeSessionId, setActiveSessionId] = useState("web");
const [sessionLoading, setSessionLoading] = useState(false);
const [textStreaming, setTextStreaming] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const [statusVisible, setStatusVisible] = useState(false);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const refs: RTCRefs = {
pcRef: useRef<RTCPeerConnection | null>(null),
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
localTracksRef: useRef<MediaStreamTrack[]>([]),
};
const refs = useRtcRefs();
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textOnlyRef = useRef(false);
const { sendTextMessage } = useBackendActions();
const { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage } =
useMessageState();
const textOnlyRef = useRef(true);
const activeSessionIdRef = useRef("web");
const {
agentState,
setAgentState,
logLines,
cards,
workbenchItems,
appendLine,
replaceLines,
clearLines,
clearCards,
clearWorkbench,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadPersistedCards,
loadWorkbench,
onDcMessage,
onIncomingMessage,
suppressRtcTextRef,
} = useMessageState(activeSessionIdRef);
const { sendTextMessage: sendTextMessageRaw } = useBackendActions({
onMessage: onIncomingMessage,
suppressRtcTextRef,
getActiveSessionId: () => activeSessionIdRef.current,
setTextStreaming,
});
const activeSession = sessions.find((session) => session.chat_id === activeSessionId) ?? null;
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const refreshSessions = useCallback(async () => {
const nextSessions = await fetchSessionsFromBackend();
if (nextSessions) setSessions(nextSessions);
return nextSessions;
}, []);
const setTextOnly = useCallback((enabled: boolean) => {
textOnlyRef.current = enabled;
setTextOnlyState(enabled);
}, []);
const showStatus = useCallback((text: string, persistMs = 0) => {
setVoiceStatus(text);
setStatusVisible(true);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
if (persistMs > 0)
if (persistMs > 0) {
statusTimerRef.current = setTimeout(() => setStatusVisible(false), persistMs);
}
}, []);
const sendJson = useCallback(
(msg: ClientMessage) => {
const dc = refs.dcRef.current;
if (dc?.readyState === "open") dc.send(JSON.stringify(msg));
if (dc?.readyState !== "open") return;
if (msg.type === "command" || msg.type === "voice-ptt") {
dc.send(JSON.stringify({ ...msg, chat_id: activeSessionIdRef.current }));
return;
}
dc.send(JSON.stringify(msg));
},
[refs.dcRef],
);
useCardPolling(loadPersistedCards);
const sendTextMessage = useCallback(
async (text: string, metadata?: CardMessageMetadata) => {
await sendTextMessageRaw(text, metadata);
await refreshSessions();
},
[refreshSessions, sendTextMessageRaw],
);
const switchSession = useCallback(async (chatId: string) => {
if (!chatId || chatId === activeSessionIdRef.current) return;
activeSessionIdRef.current = chatId;
setActiveSessionId(chatId);
}, []);
const createSession = useCallback(
async (title = "") => {
setSessionLoading(true);
try {
const created = await createSessionInBackend(title);
const nextSessions = await refreshSessions();
if (!created) return;
const nextSessionId = created.chat_id;
activeSessionIdRef.current = nextSessionId;
setActiveSessionId(nextSessionId);
if (nextSessions && !nextSessions.some((session) => session.chat_id === nextSessionId)) {
setSessions((prev) => [created, ...prev]);
}
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
const renameSession = useCallback(
async (chatId: string, title: string) => {
if (!chatId) return;
setSessionLoading(true);
try {
const updated = await renameSessionInBackend(chatId, title);
const nextSessions = await refreshSessions();
if (!updated) return;
if (nextSessions && !nextSessions.some((session) => session.chat_id === updated.chat_id)) {
setSessions((prev) => [
updated,
...prev.filter((session) => session.chat_id !== updated.chat_id),
]);
}
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
const deleteSession = useCallback(
async (chatId: string) => {
if (!chatId) return;
setSessionLoading(true);
try {
const deleted = await deleteSessionInBackend(chatId);
const nextSessions = await refreshSessions();
if (!deleted) return;
if (chatId !== activeSessionIdRef.current) return;
const fallbackSessionId =
nextSessions?.find((session) => session.chat_id !== chatId)?.chat_id || "web";
activeSessionIdRef.current = fallbackSessionId;
setActiveSessionId(fallbackSessionId);
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
useEffect(() => {
refreshSessions().then((nextSessions) => {
if (
!nextSessions ||
nextSessions.some((session) => session.chat_id === activeSessionIdRef.current)
) {
return;
}
if (nextSessions[0]?.chat_id) {
activeSessionIdRef.current = nextSessions[0].chat_id;
setActiveSessionId(nextSessions[0].chat_id);
}
});
}, [refreshSessions]);
useEffect(() => {
let cancelled = false;
const loadSession = async () => {
setSessionLoading(true);
setAgentState(() => "idle");
clearLines();
clearCards();
clearWorkbench();
try {
const detail = await fetchSessionDetailFromBackend(activeSessionId);
if (cancelled || activeSessionIdRef.current !== activeSessionId) return;
if (detail) {
replaceLines(detail.messages);
setSessions((prev) => {
const filtered = prev.filter((session) => session.chat_id !== detail.session.chat_id);
return [detail.session, ...filtered].sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
);
});
}
await loadPersistedCards();
await loadWorkbench();
} finally {
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
setSessionLoading(false);
}
}
};
loadSession().catch((err) => {
console.warn("[sessions] failed to load session", err);
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
setSessionLoading(false);
}
});
return () => {
cancelled = true;
};
}, [
activeSessionId,
clearCards,
clearLines,
clearWorkbench,
loadPersistedCards,
loadWorkbench,
replaceLines,
setAgentState,
]);
useSessionSurfaceEvents({
activeSessionId,
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
});
useRemoteAudioBindings({
textOnly,
connected,
@ -745,6 +264,7 @@ export function useWebRTC(): WebRTCState {
appendLine,
onDcMessage,
loadPersistedCards,
loadWorkbench,
showStatus,
refs,
setConnected,
@ -760,6 +280,12 @@ export function useWebRTC(): WebRTCState {
agentState,
logLines,
cards,
workbenchItems,
sessions,
activeSessionId,
activeSession,
sessionLoading,
textStreaming,
voiceStatus,
statusVisible,
remoteAudioEl: refs.remoteAudioRef.current,
@ -767,7 +293,13 @@ export function useWebRTC(): WebRTCState {
sendJson,
sendTextMessage,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
setTextOnly,
switchSession,
createSession,
renameSession,
deleteSession,
connect,
};
}

View file

@ -0,0 +1,140 @@
import { useCallback } from "preact/hooks";
import { streamSseResponse } from "../../lib/sse";
import type { CardMessageMetadata, SessionSummary } from "../../types";
import type { RawPersistedCard, RawWorkbenchItem, SessionDetailResponse } from "./types";
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
export function useBackendActions({
onMessage,
suppressRtcTextRef,
getActiveSessionId,
setTextStreaming,
}: {
onMessage: (raw: string) => void;
suppressRtcTextRef: { current: number };
getActiveSessionId: () => string;
setTextStreaming: (value: boolean) => void;
}) {
const sendTextMessage = useCallback(
async (text: string, metadata?: CardMessageMetadata) => {
const message = text.trim();
if (!message) return;
suppressRtcTextRef.current += 1;
setTextStreaming(true);
try {
const url = BACKEND_URL ? `${BACKEND_URL}/message/stream` : "/message/stream";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: message,
metadata: metadata ?? {},
chat_id: getActiveSessionId(),
}),
});
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
await streamSseResponse(resp, onMessage);
} finally {
setTextStreaming(false);
suppressRtcTextRef.current = Math.max(0, suppressRtcTextRef.current - 1);
}
},
[getActiveSessionId, onMessage, setTextStreaming, suppressRtcTextRef],
);
return { sendTextMessage };
}
export async function fetchPersistedCardsFromBackend(
chatId: string,
): Promise<RawPersistedCard[] | null> {
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL ? `${BACKEND_URL}/cards?${params}` : `/cards?${params}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return null;
}
return (await resp.json()) as RawPersistedCard[];
}
export async function fetchWorkbenchFromBackend(
chatId: string,
): Promise<RawWorkbenchItem[] | null> {
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL ? `${BACKEND_URL}/workbench?${params}` : `/workbench?${params}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[workbench] /workbench returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { items?: RawWorkbenchItem[] };
return Array.isArray(payload.items) ? payload.items : null;
}
export async function fetchSessionsFromBackend(): Promise<SessionSummary[] | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[sessions] /sessions returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { sessions?: SessionSummary[] };
return Array.isArray(payload.sessions) ? payload.sessions : null;
}
export async function fetchSessionDetailFromBackend(
chatId: string,
): Promise<SessionDetailResponse | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[sessions] /sessions/${chatId} returned ${resp.status}`);
return null;
}
return (await resp.json()) as SessionDetailResponse;
}
export async function createSessionInBackend(title = ""): Promise<SessionSummary | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!resp.ok) {
console.warn(`[sessions] POST /sessions returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { session?: SessionSummary };
return payload.session ?? null;
}
export async function renameSessionInBackend(
chatId: string,
title: string,
): Promise<SessionSummary | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!resp.ok) {
console.warn(`[sessions] PATCH /sessions/${chatId} returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { session?: SessionSummary };
return payload.session ?? null;
}
export async function deleteSessionInBackend(chatId: string): Promise<boolean> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok) {
console.warn(`[sessions] DELETE /sessions/${chatId} returned ${resp.status}`);
return false;
}
return true;
}

View file

@ -0,0 +1,543 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { getCardLiveContent } from "../../cardRuntime/store";
import type { CardItem, CardState, JsonValue, SessionSummary, WorkbenchItem } from "../../types";
import { BACKEND_URL, fetchPersistedCardsFromBackend, fetchWorkbenchFromBackend } from "./backend";
import type { RawPersistedCard, RawWorkbenchItem } from "./types";
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
let cardIdCounter = 0;
const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
if (!card.serverId) return card.priority;
const liveContent = getCardLiveContent(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, unknown>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
function compareCards(a: CardItem, b: CardItem): number {
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const createdDiff = b.createdAt.localeCompare(a.createdAt);
if (createdDiff !== 0) return createdDiff;
return a.id - b.id;
}
function readTaskGroupKey(
card: Pick<CardItem, "serverId" | "slot" | "templateKey" | "templateState">,
): string {
const raw = card.templateState;
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const taskKey = raw.task_key;
if (typeof taskKey === "string" && taskKey.trim()) return taskKey.trim();
}
return "";
}
function isTaskParentCard(card: Pick<CardItem, "templateKey" | "templateState">): boolean {
return card.templateKey === "todo-item-live" && !!readTaskGroupKey(card);
}
function isLinkedHelperCard(card: Pick<CardItem, "serverId" | "slot" | "templateState">): boolean {
const taskGroupKey = readTaskGroupKey(card);
if (!taskGroupKey) return false;
const raw = card.templateState;
const helperKind =
raw && typeof raw === "object" && !Array.isArray(raw) ? raw.helper_kind : undefined;
return (
(typeof helperKind === "string" && !!helperKind.trim()) ||
(card.serverId ?? "").startsWith("task-helper-") ||
(card.slot ?? "").startsWith("taskhelper:")
);
}
interface TaskLinkedCardIndex {
parentGroups: Set<string>;
helpersByGroup: Map<string, CardItem[]>;
}
function indexTaskLinkedCards(base: CardItem[]): TaskLinkedCardIndex {
const parentGroups = new Set<string>();
const helpersByGroup = new Map<string, CardItem[]>();
for (const card of base) {
const groupKey = readTaskGroupKey(card);
if (!groupKey) continue;
if (isTaskParentCard(card)) {
parentGroups.add(groupKey);
continue;
}
if (!isLinkedHelperCard(card)) continue;
const current = helpersByGroup.get(groupKey);
if (current) current.push(card);
else helpersByGroup.set(groupKey, [card]);
}
return { parentGroups, helpersByGroup };
}
function shouldDeferLinkedHelper(card: CardItem, index: TaskLinkedCardIndex): boolean {
const groupKey = readTaskGroupKey(card);
return !!groupKey && isLinkedHelperCard(card) && index.parentGroups.has(groupKey);
}
function appendGroupedTaskHelpers(
grouped: CardItem[],
emitted: Set<number>,
card: CardItem,
index: TaskLinkedCardIndex,
): void {
const groupKey = readTaskGroupKey(card);
if (!groupKey || !isTaskParentCard(card)) return;
const helpers = index.helpersByGroup.get(groupKey) ?? [];
for (const helper of helpers) {
if (emitted.has(helper.id)) continue;
grouped.push(helper);
emitted.add(helper.id);
}
}
function sortCards(items: CardItem[]): CardItem[] {
const base = [...items].sort(compareCards);
const linkedIndex = indexTaskLinkedCards(base);
const emitted = new Set<number>();
const grouped: CardItem[] = [];
for (const card of base) {
if (emitted.has(card.id)) continue;
if (shouldDeferLinkedHelper(card, linkedIndex)) continue;
grouped.push(card);
emitted.add(card.id);
appendGroupedTaskHelpers(grouped, emitted, card, linkedIndex);
}
return grouped;
}
function toCardItem(msg: Extract<RawPersistedCard, { type?: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function mergeCardItem(prev: CardItem[], item: Omit<CardItem, "id">): CardItem[] {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
}
function normalizePersistedCard(raw: RawPersistedCard): Omit<CardItem, "id"> {
return toCardItem({
type: "card",
...(raw as Omit<Extract<RawPersistedCard, { type?: "card" }>, "type">),
});
}
function areEquivalentCardItems(
existing: CardItem,
next: Omit<CardItem, "id"> & { id: number },
): boolean {
return (
existing.serverId === next.serverId &&
existing.kind === next.kind &&
existing.content === next.content &&
existing.title === next.title &&
existing.question === next.question &&
existing.responseValue === next.responseValue &&
existing.slot === next.slot &&
existing.lane === next.lane &&
existing.priority === next.priority &&
existing.state === next.state &&
existing.templateKey === next.templateKey &&
existing.contextSummary === next.contextSummary &&
existing.createdAt === next.createdAt &&
areStringArraysEqual(existing.choices, next.choices) &&
areJsonRecordsEqual(existing.templateState, next.templateState)
);
}
function areStringArraysEqual(left: string[] | undefined, right: string[] | undefined): boolean {
if (left === right) return true;
if (!left || !right) return !left && !right;
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function areJsonRecordsEqual(
left: Record<string, JsonValue> | undefined,
right: Record<string, JsonValue> | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return !left && !right;
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card]),
);
const next = rawCards.map((raw) => {
const card = normalizePersistedCard(raw);
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
return {
...card,
id: existing?.id ?? cardIdCounter++,
};
});
const reused = next.map((card) => {
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
if (!existing) return card;
return areEquivalentCardItems(existing, card) ? existing : card;
});
const sorted = sortCards(reused);
if (sorted.length === prev.length && sorted.every((card, index) => card === prev[index])) {
return prev;
}
return sorted;
}
function toWorkbenchItem(msg: Extract<RawWorkbenchItem, { type?: "workbench" }>): WorkbenchItem {
return {
id: msg.id,
chatId: msg.chat_id,
kind: msg.kind,
title: msg.title,
content: msg.content,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
promotable: msg.promotable !== false,
sourceCardId: msg.source_card_id || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function normalizePersistedWorkbench(raw: RawWorkbenchItem): WorkbenchItem {
return toWorkbenchItem({
type: "workbench",
...(raw as Omit<Extract<RawWorkbenchItem, { type?: "workbench" }>, "type">),
});
}
function sortWorkbenchItems(items: WorkbenchItem[]): WorkbenchItem[] {
return [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
function mergeWorkbenchItem(prev: WorkbenchItem[], item: WorkbenchItem): WorkbenchItem[] {
const existingIndex = prev.findIndex((entry) => entry.id === item.id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortWorkbenchItems(next);
}
return sortWorkbenchItems([item, ...prev]);
}
function reconcilePersistedWorkbench(
prev: WorkbenchItem[],
rawItems: RawWorkbenchItem[],
): WorkbenchItem[] {
const byId = new Map(prev.map((item) => [item.id, item]));
const next = rawItems.map((raw) => {
const item = normalizePersistedWorkbench(raw);
const existing = byId.get(item.id);
return existing ? { ...existing, ...item } : item;
});
return sortWorkbenchItems(next);
}
interface SessionSurfaceEventsOptions {
activeSessionId: string;
loadPersistedCards: () => Promise<void>;
loadWorkbench: () => Promise<void>;
refreshSessions: () => Promise<SessionSummary[] | null>;
setSessions: (sessions: SessionSummary[]) => void;
}
function parseUiEventPayload(raw: string): Record<string, unknown> | null {
try {
const payload = JSON.parse(raw) as Record<string, unknown>;
return payload && typeof payload === "object" ? payload : null;
} catch {
return null;
}
}
function handleSessionSurfaceEvent(
payload: Record<string, unknown>,
options: Pick<
SessionSurfaceEventsOptions,
"loadPersistedCards" | "loadWorkbench" | "refreshSessions" | "setSessions"
>,
): void {
if (typeof payload.type !== "string") return;
const { loadPersistedCards, loadWorkbench, refreshSessions, setSessions } = options;
switch (payload.type) {
case "cards.changed":
loadPersistedCards().catch(() => {});
return;
case "workbench.changed":
loadWorkbench().catch(() => {});
return;
case "sessions.changed":
if (Array.isArray(payload.sessions)) {
setSessions(payload.sessions as SessionSummary[]);
return;
}
refreshSessions().catch(() => {});
return;
case "inbox.changed":
window.dispatchEvent(
new CustomEvent(INBOX_REFRESH_EVENT, {
detail: { items: Array.isArray(payload.items) ? payload.items : undefined },
}),
);
return;
default:
return;
}
}
export function useSessionSurfaceEvents({
activeSessionId,
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
}: SessionSurfaceEventsOptions) {
useEffect(() => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
let fallbackPollId: number | null = null;
let source: EventSource | null = null;
const connectEvents = () => {
const params = new URLSearchParams({ chat_id: activeSessionId });
const url = BACKEND_URL ? `${BACKEND_URL}/events?${params}` : `/events?${params}`;
source = new EventSource(url);
source.onopen = () => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
};
source.onmessage = (event) => {
const payload = parseUiEventPayload(event.data);
if (!payload) return;
handleSessionSurfaceEvent(payload, {
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
});
};
};
if (typeof EventSource === "function") {
connectEvents();
} else {
fallbackPollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
}, 5000);
}
const onVisible = () => {
if (document.visibilityState === "visible") {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
}
};
const onCardsRefresh = () => {
loadPersistedCards().catch(() => {});
};
const onWorkbenchRefresh = () => {
loadWorkbench().catch(() => {});
};
window.addEventListener("focus", onVisible);
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
window.addEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
document.addEventListener("visibilitychange", onVisible);
return () => {
if (fallbackPollId !== null) window.clearInterval(fallbackPollId);
source?.close();
window.removeEventListener("focus", onVisible);
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
window.removeEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
document.removeEventListener("visibilitychange", onVisible);
};
}, [activeSessionId, loadPersistedCards, loadWorkbench, refreshSessions, setSessions]);
}
export function useCardsState(activeSessionIdRef: { current: string }) {
const [cards, setCards] = useState<CardItem[]>([]);
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => mergeCardItem(prev, item));
}, []);
const dismissCard = useCallback((id: number) => {
setCards((prev) => {
const card = prev.find((entry) => entry.id === id);
if (card?.serverId) {
const url = BACKEND_URL
? `${BACKEND_URL}/cards/${card.serverId}`
: `/cards/${card.serverId}`;
fetch(url, { method: "DELETE" }).catch(() => {});
}
return prev.filter((entry) => entry.id !== id);
});
}, []);
const loadPersistedCards = useCallback(async () => {
try {
const rawCards = await fetchPersistedCardsFromBackend(activeSessionIdRef.current);
if (!rawCards) return;
setCards((prev) => reconcilePersistedCards(prev, rawCards));
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, [activeSessionIdRef]);
const clearCards = useCallback(() => {
setCards([]);
}, []);
useEffect(() => {
const onCardLiveContentChange = (event: Event) => {
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
const cardId =
customEvent.detail && typeof customEvent.detail.cardId === "string"
? customEvent.detail.cardId
: "";
setCards((prev) => {
if (prev.length === 0) return prev;
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
return sortCards([...prev]);
});
};
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
return () => {
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
};
}, []);
return { cards, upsertCard, dismissCard, loadPersistedCards, clearCards };
}
export function useWorkbenchState(activeSessionIdRef: { current: string }) {
const [workbenchItems, setWorkbenchItems] = useState<WorkbenchItem[]>([]);
const upsertWorkbench = useCallback((item: WorkbenchItem) => {
setWorkbenchItems((prev) => mergeWorkbenchItem(prev, item));
}, []);
const dismissWorkbenchItem = useCallback(
async (id: string) => {
const key = id.trim();
if (!key) return;
const chatId = activeSessionIdRef.current;
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}?${params}`
: `/workbench/${encodeURIComponent(key)}?${params}`;
await fetch(url, { method: "DELETE" }).catch(() => {});
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
},
[activeSessionIdRef],
);
const promoteWorkbenchItem = useCallback(
async (id: string) => {
const key = id.trim();
if (!key) return;
const chatId = activeSessionIdRef.current;
const url = BACKEND_URL
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}/promote`
: `/workbench/${encodeURIComponent(key)}/promote`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId }),
});
if (!resp.ok) throw new Error(`promote failed (${resp.status})`);
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
window.dispatchEvent(new Event("nanobot:cards-refresh"));
},
[activeSessionIdRef],
);
const loadWorkbench = useCallback(async () => {
try {
const rawItems = await fetchWorkbenchFromBackend(activeSessionIdRef.current);
if (!rawItems) return;
setWorkbenchItems((prev) => reconcilePersistedWorkbench(prev, rawItems));
} catch (err) {
console.warn("[workbench] failed to load workbench items", err);
}
}, [activeSessionIdRef]);
const clearWorkbench = useCallback(() => {
setWorkbenchItems([]);
}, []);
return {
workbenchItems,
upsertWorkbench,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadWorkbench,
clearWorkbench,
};
}

View file

@ -0,0 +1,399 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type {
AgentState,
CardItem,
LogLine,
ServerMessage,
SessionHistoryLine,
WorkbenchItem,
} from "../../types";
import { useCardsState, useWorkbenchState } from "./cards";
import type { AppendLine, IdleFallbackControls, SetAgentState, UpsertCard } from "./types";
let logIdCounter = 0;
function hydrateLogLines(entries: SessionHistoryLine[]): LogLine[] {
return entries.map((entry) => ({
id: logIdCounter++,
role: entry.role,
text: entry.text,
timestamp: entry.timestamp || new Date().toISOString(),
contextLabel: undefined,
}));
}
function appendLogLineEntry(
prev: LogLine[],
role: string,
text: string,
timestamp: string,
contextLabel?: string,
): LogLine[] {
const next = [
...prev,
{
id: logIdCounter++,
role,
text,
timestamp: timestamp || new Date().toISOString(),
contextLabel: contextLabel?.trim() || undefined,
},
];
return next.length > 250 ? next.slice(next.length - 250) : next;
}
function appendStreamingAssistantEntry(
prev: LogLine[],
text: string,
timestamp: string,
): LogLine[] {
const nextTimestamp = timestamp || new Date().toISOString();
const last = prev[prev.length - 1];
if (last?.role === "nanobot-progress") {
const next = [...prev];
next[next.length - 1] = {
...last,
text: `${last.text}${text}`,
timestamp: nextTimestamp,
};
return next;
}
return appendLogLineEntry(prev, "nanobot-progress", text, nextTimestamp);
}
function finalizeStreamingAssistantEntry(
prev: LogLine[],
text: string,
timestamp: string,
): LogLine[] {
const nextTimestamp = timestamp || new Date().toISOString();
const last = prev[prev.length - 1];
if (last?.role === "nanobot-progress") {
const next = [...prev];
next[next.length - 1] = {
...last,
role: "nanobot",
text,
timestamp: nextTimestamp,
};
return next;
}
return appendLogLineEntry(prev, "nanobot", text, nextTimestamp);
}
interface MessageHandlers {
setAgentState: SetAgentState;
appendLine: AppendLine;
appendAssistantDelta: AppendLine;
finalizeAssistantLine: AppendLine;
upsertCard: UpsertCard;
upsertWorkbench: (item: WorkbenchItem) => void;
idleFallback: IdleFallbackControls;
}
function handleAgentStateMessage(
msg: Extract<ServerMessage, { type: "agent_state" }>,
handlers: Pick<MessageHandlers, "idleFallback" | "setAgentState">,
): void {
handlers.idleFallback.clear();
handlers.setAgentState(() => msg.state);
}
function handleTextMessage(
msg: Extract<ServerMessage, { type: "message" }>,
handlers: Pick<
MessageHandlers,
"appendAssistantDelta" | "appendLine" | "finalizeAssistantLine" | "idleFallback"
>,
): void {
if (msg.is_tool_hint) {
handlers.appendLine("tool", msg.content, msg.timestamp);
return;
}
if (msg.is_progress) {
handlers.appendAssistantDelta("nanobot-progress", msg.content, msg.timestamp);
return;
}
handlers.finalizeAssistantLine(msg.role, msg.content, msg.timestamp, msg.context_label);
handlers.idleFallback.schedule();
}
function toIncomingCard(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function toIncomingWorkbench(msg: Extract<ServerMessage, { type: "workbench" }>): WorkbenchItem {
return {
id: msg.id,
chatId: msg.chat_id,
kind: msg.kind,
title: msg.title,
content: msg.content,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
promotable: msg.promotable !== false,
sourceCardId: msg.source_card_id || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
handlers: MessageHandlers,
): void {
switch (msg.type) {
case "agent_state":
handleAgentStateMessage(msg, handlers);
return;
case "message":
handleTextMessage(msg, handlers);
return;
case "card":
handlers.upsertCard(toIncomingCard(msg));
handlers.idleFallback.schedule();
return;
case "workbench":
handlers.upsertWorkbench(toIncomingWorkbench(msg));
handlers.idleFallback.schedule();
return;
case "error":
handlers.appendLine("system", msg.error, "");
handlers.idleFallback.schedule();
return;
default:
return;
}
}
function parseServerMessage(raw: string): Extract<ServerMessage, { type: string }> | null {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return null;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return null;
return msg as Extract<ServerMessage, { type: string }>;
}
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (!idleTimerRef.current) return;
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}, []);
const schedule = useCallback(
(delayMs = 450) => {
clear();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clear, setAgentState],
);
useEffect(() => clear, [clear]);
return { clear, schedule };
}
function useLogState() {
const [logLines, setLogLines] = useState<LogLine[]>([]);
const appendLine = useCallback(
(role: string, text: string, timestamp: string, contextLabel?: string) => {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
},
[],
);
const appendAssistantDelta = useCallback((_: string, text: string, timestamp: string) => {
setLogLines((prev) => appendStreamingAssistantEntry(prev, text, timestamp));
}, []);
const finalizeAssistantLine = useCallback(
(role: string, text: string, timestamp: string, contextLabel?: string) => {
if (role !== "nanobot") {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
return;
}
setLogLines((prev) => finalizeStreamingAssistantEntry(prev, text, timestamp));
},
[],
);
const replaceLines = useCallback((entries: SessionHistoryLine[]) => {
setLogLines(hydrateLogLines(entries));
}, []);
const clearLines = useCallback(() => {
setLogLines([]);
}, []);
return {
logLines,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
replaceLines,
clearLines,
};
}
function useIncomingMessages({
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
}: {
setAgentState: SetAgentState;
appendLine: AppendLine;
appendAssistantDelta: AppendLine;
finalizeAssistantLine: AppendLine;
upsertCard: UpsertCard;
upsertWorkbench: (item: WorkbenchItem) => void;
idleFallback: IdleFallbackControls;
}) {
return useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
handleTypedMessage(msg, {
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
},
[
appendAssistantDelta,
appendLine,
finalizeAssistantLine,
idleFallback,
setAgentState,
upsertCard,
upsertWorkbench,
],
);
}
export function useMessageState(activeSessionIdRef: { current: string }) {
const [agentState, setAgentState] = useState<AgentState>("idle");
const {
logLines,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
replaceLines,
clearLines,
} = useLogState();
const { cards, upsertCard, dismissCard, loadPersistedCards, clearCards } =
useCardsState(activeSessionIdRef);
const {
workbenchItems,
upsertWorkbench,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadWorkbench,
clearWorkbench,
} = useWorkbenchState(activeSessionIdRef);
const idleFallback = useIdleFallback(setAgentState);
const suppressRtcTextRef = useRef(0);
const onIncomingMessage = useIncomingMessages({
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
const onDcMessage = useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
if (
suppressRtcTextRef.current > 0 &&
(msg.type === "message" || msg.type === "agent_state" || msg.type === "card")
) {
return;
}
handleTypedMessage(msg, {
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
},
[
appendAssistantDelta,
appendLine,
finalizeAssistantLine,
idleFallback,
setAgentState,
upsertCard,
upsertWorkbench,
],
);
return {
agentState,
setAgentState,
logLines,
cards,
workbenchItems,
appendLine,
replaceLines,
clearLines,
clearCards,
clearWorkbench,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadPersistedCards,
loadWorkbench,
onDcMessage,
onIncomingMessage,
suppressRtcTextRef,
};
}

View file

@ -0,0 +1,319 @@
import { useCallback, useEffect, useRef } from "preact/hooks";
import type { ClientMessage } from "../../types";
import { BACKEND_URL } from "./backend";
import type { AppendLine, RTCCallbacks, RTCRefs } from "./types";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
function stopMediaTracks(tracks: Iterable<MediaStreamTrack | null | undefined>): void {
const seen = new Set<MediaStreamTrack>();
for (const track of tracks) {
if (!track || seen.has(track)) continue;
seen.add(track);
track.stop();
}
}
async function acquireMicStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
},
video: false,
});
} catch {
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
}
}
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
return new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") {
resolve();
return;
}
const check = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(() => {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
});
}
async function exchangeSdp(
localDesc: RTCSessionDescription,
): Promise<{ sdp: string; rtcType: string }> {
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
const resp = await fetch(rtcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
});
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
}
async function runConnect(
refs: RTCRefs,
cbs: RTCCallbacks,
opts: { textOnly: boolean },
): Promise<void> {
if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
return;
}
cbs.setConnecting(true);
cbs.showStatus("Connecting...");
let micStream: MediaStream | null = null;
try {
refs.localTracksRef.current = [];
if (!opts.textOnly) {
micStream = await acquireMicStream();
const audioTracks = micStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = false;
});
refs.localTracksRef.current = audioTracks;
}
const pc = new RTCPeerConnection(
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
);
refs.pcRef.current = pc;
const newRemoteStream = new MediaStream();
cbs.setRemoteStream(newRemoteStream);
if (refs.remoteAudioRef.current) {
refs.remoteAudioRef.current.srcObject = newRemoteStream;
refs.remoteAudioRef.current.play().catch(() => {});
}
pc.ontrack = (event) => {
if (event.track.kind !== "audio") return;
newRemoteStream.addTrack(event.track);
refs.remoteAudioRef.current?.play().catch(() => {});
};
const dc = pc.createDataChannel("app", { ordered: true });
refs.dcRef.current = dc;
dc.onopen = () => {
cbs.setConnected(true);
cbs.setConnecting(false);
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
};
dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
cbs.closePC();
};
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
refs.micSendersRef.current = [];
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceComplete(pc);
const localDesc = pc.localDescription;
if (!localDesc) throw new Error("No local description after ICE gathering");
const answer = await exchangeSdp(localDesc);
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
} catch (err) {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
}
}
export function useRtcRefs(): RTCRefs {
return {
pcRef: useRef<RTCPeerConnection | null>(null),
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
localTracksRef: useRef<MediaStreamTrack[]>([]),
};
}
export function useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef,
micSendersRef,
dcRef,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
showStatus: (text: string, persistMs?: number) => void;
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
dcRef: { current: RTCDataChannel | null };
textOnlyRef: { current: boolean };
}) {
useEffect(() => {
textOnlyRef.current = textOnly;
}, [textOnly, textOnlyRef]);
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
audio.muted = textOnlyRef.current;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
}, [remoteAudioRef, textOnlyRef]);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, [micSendersRef, textOnlyRef]);
useEffect(() => {
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = textOnly;
if (textOnly) remoteAudioRef.current.pause();
else remoteAudioRef.current.play().catch(() => {});
}
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = false;
});
if (textOnly) {
const dc = dcRef.current;
if (dc?.readyState === "open") {
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
}
}
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
export function usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
loadWorkbench,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
loadPersistedCards: () => Promise<void>;
loadWorkbench: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
refs: RTCRefs;
setConnected: (value: boolean) => void;
setConnecting: (value: boolean) => void;
setRemoteStream: (stream: MediaStream | null) => void;
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
const dc = refs.dcRef.current;
const pc = refs.pcRef.current;
const localTracks = refs.localTracksRef.current;
const senderTracks = refs.micSendersRef.current.map((sender) => sender.track);
refs.dcRef.current = null;
refs.pcRef.current = null;
refs.micSendersRef.current = [];
refs.localTracksRef.current = [];
stopMediaTracks([...senderTracks, ...localTracks]);
dc?.close();
pc?.close();
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const closePCRef = useRef(closePC);
useEffect(() => {
closePCRef.current = closePC;
}, [closePC]);
const connect = useCallback(async () => {
await runConnect(
refs,
{
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
},
closePC,
},
{ textOnly: textOnlyRef.current },
);
}, [
appendLine,
closePC,
loadPersistedCards,
loadWorkbench,
onDcMessage,
refs,
setConnected,
setConnecting,
setRemoteStream,
showStatus,
textOnlyRef,
]);
useEffect(() => {
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
closePC();
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
useEffect(() => {
return () => {
closePCRef.current();
};
}, []);
return { closePC, connect };
}

View file

@ -0,0 +1,87 @@
import type {
AgentState,
CardItem,
CardMessageMetadata,
ClientMessage,
LogLine,
ServerMessage,
SessionHistoryLine,
SessionSummary,
WorkbenchItem,
} from "../../types";
export interface WebRTCState {
connected: boolean;
connecting: boolean;
textOnly: boolean;
agentState: AgentState;
logLines: LogLine[];
cards: CardItem[];
workbenchItems: WorkbenchItem[];
sessions: SessionSummary[];
activeSessionId: string;
activeSession: SessionSummary | null;
sessionLoading: boolean;
textStreaming: boolean;
voiceStatus: string;
statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
dismissWorkbenchItem(id: string): Promise<void>;
promoteWorkbenchItem(id: string): Promise<void>;
setTextOnly(enabled: boolean): void;
switchSession(chatId: string): Promise<void>;
createSession(title?: string): Promise<void>;
renameSession(chatId: string, title: string): Promise<void>;
deleteSession(chatId: string): Promise<void>;
connect(): Promise<void>;
}
export type AppendLine = (
role: string,
text: string,
timestamp: string,
contextLabel?: string,
) => void;
export type UpsertCard = (item: Omit<CardItem, "id">) => void;
export type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
export type RawPersistedCard =
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
export type RawWorkbenchItem =
| Extract<ServerMessage, { type: "workbench" }>
| (Omit<Extract<ServerMessage, { type: "workbench" }>, "type"> & {
type?: "workbench";
});
export interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
export interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
localTracksRef: { current: MediaStreamTrack[] };
}
export interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
onDcOpen: () => void;
closePC: () => void;
}
export interface SessionDetailResponse {
session: SessionSummary;
messages: SessionHistoryLine[];
}

File diff suppressed because it is too large Load diff

49
frontend/src/lib/sse.ts Normal file
View file

@ -0,0 +1,49 @@
export async function streamSseResponse(
response: Response,
onMessage: (raw: string) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
let eventData = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
({ buffer, eventData } = consumeSseBuffer(buffer, eventData, onMessage));
if (done) break;
}
const tail = `${eventData}\n${buffer}`.trim();
if (tail) onMessage(tail);
}
function consumeSseBuffer(
rawBuffer: string,
rawEventData: string,
onMessage: (raw: string) => void,
): { buffer: string; eventData: string } {
let buffer = rawBuffer;
let eventData = rawEventData;
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
eventData = consumeSseLine(line, eventData, onMessage);
newlineIndex = buffer.indexOf("\n");
}
return { buffer, eventData };
}
function consumeSseLine(line: string, eventData: string, onMessage: (raw: string) => void): string {
if (!line) {
const payload = eventData.trim();
if (payload) onMessage(payload);
return "";
}
if (!line.startsWith("data:")) return eventData;
const chunk = line.slice(5).trimStart();
return eventData ? `${eventData}\n${chunk}` : chunk;
}

View file

@ -1,6 +1,8 @@
import { render } from "preact";
import { App } from "./App";
import "./index.css";
import { applyThemeToDocument, getStoredThemeName } from "./theme/themes";
const root = document.getElementById("app");
applyThemeToDocument(getStoredThemeName());
if (root) render(<App />, root);

View file

@ -0,0 +1,252 @@
export type ThemeName = "clay" | "sage" | "mist";
export interface ThemeOption {
name: ThemeName;
label: string;
swatch: string;
tokens: Record<string, string>;
}
export const THEME_STORAGE_KEY = "nanobot.theme";
export const DEFAULT_THEME: ThemeName = "clay";
const cardFonts = {
"--card-font": '"Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
"--theme-font-title": '"IBM Plex Sans Condensed", "SF Pro Display", "Arial Narrow", sans-serif',
"--theme-font-mono": '"M-1m Code", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
};
export const THEME_OPTIONS: ThemeOption[] = [
{
name: "clay",
label: "Clay",
swatch: "linear-gradient(135deg, #b56c3d 0%, #d9b1b8 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #f7d9bf 0%, #f2caa8 100%)",
"--theme-agent-bg": "#ffffff",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(245, 228, 210, 0.54), transparent 34%), linear-gradient(180deg, #fcf7f1 0%, #f6efe7 100%)",
"--theme-feed-bg": "#e7ddd0",
"--theme-panel-bg": "rgba(247, 239, 230, 0.9)",
"--theme-panel-bg-strong": "rgba(250, 244, 237, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.94)",
"--theme-border": "rgba(141, 104, 75, 0.18)",
"--theme-border-strong": "rgba(120, 84, 52, 0.22)",
"--theme-text": "#2f2118",
"--theme-text-soft": "#5f4634",
"--theme-text-muted": "#8b654b",
"--theme-accent": "#b56c3d",
"--theme-accent-strong": "#99532a",
"--theme-accent-soft": "rgba(255, 200, 140, 0.2)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(0, 0, 0, 0.42)",
"--theme-shadow": "0 8px 18px rgba(48, 28, 18, 0.1)",
"--theme-shadow-strong": "0 18px 36px rgba(48, 28, 18, 0.18)",
"--theme-status-muted": "#6b7280",
"--theme-status-live": "#047857",
"--theme-status-warning": "#b45309",
"--theme-status-danger": "#b91c1c",
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)",
"--theme-card-neutral-border": "#dbe4ee",
"--theme-card-neutral-text": "#111827",
"--theme-card-neutral-muted": "#64748b",
"--theme-card-neutral-subtle": "#475569",
"--theme-card-warm-bg": "linear-gradient(180deg, #fffdfb 0%, #f7f1ea 100%)",
"--theme-card-warm-border": "rgba(168, 120, 86, 0.18)",
"--theme-card-warm-text": "#23150d",
"--theme-card-warm-muted": "#78543b",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(234, 244, 229, 0.98), rgba(218, 232, 212, 0.98))",
"--theme-card-success-border": "rgba(96, 126, 90, 0.18)",
"--theme-card-success-text": "#2f442a",
"--theme-card-success-muted": "rgba(52, 82, 45, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(253, 245, 235, 0.98), rgba(242, 227, 211, 0.97))",
"--theme-task-border": "rgba(87, 65, 50, 0.14)",
"--theme-task-text": "#2f241e",
"--theme-task-muted": "#7e6659",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.68), 0 18px 36px rgba(79, 56, 43, 0.12)",
"--theme-task-pattern": "rgba(122, 97, 78, 0.035)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(255, 252, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(244, 226, 187, 0.98), rgba(226, 198, 145, 0.97))",
"--theme-list-text": "#4d392d",
"--theme-list-muted": "rgba(77, 57, 45, 0.72)",
"--theme-list-border": "rgba(92, 70, 55, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 224, 0.62)",
"--card-surface": "linear-gradient(180deg, #b56c3d 0%, #8f4f27 100%)",
"--card-border": "rgba(255, 220, 188, 0.24)",
"--card-shadow": "0 10px 28px rgba(68, 34, 15, 0.22)",
"--card-text": "rgba(255, 245, 235, 0.9)",
"--card-muted": "rgba(255, 233, 214, 0.72)",
"--helper-card-surface": "linear-gradient(180deg, #d9b1b8 0%, #c78d9a 100%)",
"--helper-card-border": "rgba(111, 46, 63, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(92, 42, 57, 0.16)",
"--helper-card-text": "rgba(63, 23, 35, 0.94)",
"--helper-card-muted": "rgba(87, 43, 56, 0.72)",
},
},
{
name: "sage",
label: "Sage",
swatch: "linear-gradient(135deg, #6d8a5d 0%, #d7cfb4 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #dfe8d3 0%, #c8d5bf 100%)",
"--theme-agent-bg": "#fbfcf8",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(232, 242, 223, 0.54), transparent 34%), linear-gradient(180deg, #fbfcf8 0%, #f1f5eb 100%)",
"--theme-feed-bg": "#dfe6d7",
"--theme-panel-bg": "rgba(241, 246, 236, 0.92)",
"--theme-panel-bg-strong": "rgba(247, 250, 243, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
"--theme-border": "rgba(107, 129, 95, 0.18)",
"--theme-border-strong": "rgba(86, 110, 74, 0.24)",
"--theme-text": "#263126",
"--theme-text-soft": "#415141",
"--theme-text-muted": "#677767",
"--theme-accent": "#6d8a5d",
"--theme-accent-strong": "#547246",
"--theme-accent-soft": "rgba(137, 170, 117, 0.18)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(24, 34, 24, 0.34)",
"--theme-shadow": "0 8px 18px rgba(40, 60, 36, 0.12)",
"--theme-shadow-strong": "0 18px 36px rgba(40, 60, 36, 0.16)",
"--theme-status-muted": "#5f6b5f",
"--theme-status-live": "#2f6b47",
"--theme-status-warning": "#9a641d",
"--theme-status-danger": "#a13f35",
"--theme-card-neutral-bg": "linear-gradient(180deg, #fcfdf9 0%, #eff4e8 100%)",
"--theme-card-neutral-border": "rgba(122, 145, 109, 0.22)",
"--theme-card-neutral-text": "#223022",
"--theme-card-neutral-muted": "#607260",
"--theme-card-neutral-subtle": "#4b5f4b",
"--theme-card-warm-bg": "linear-gradient(180deg, #f8f7ef 0%, #ece7d8 100%)",
"--theme-card-warm-border": "rgba(150, 134, 98, 0.22)",
"--theme-card-warm-text": "#353024",
"--theme-card-warm-muted": "#776950",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(229, 241, 220, 0.98), rgba(211, 229, 201, 0.98))",
"--theme-card-success-border": "rgba(98, 129, 86, 0.2)",
"--theme-card-success-text": "#29412a",
"--theme-card-success-muted": "rgba(56, 82, 49, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.68), transparent 32%), linear-gradient(145deg, rgba(248, 250, 240, 0.98), rgba(231, 236, 219, 0.97))",
"--theme-task-border": "rgba(95, 118, 89, 0.14)",
"--theme-task-text": "#243125",
"--theme-task-muted": "#647464",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(54, 72, 48, 0.12)",
"--theme-task-pattern": "rgba(98, 118, 90, 0.03)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(255, 249, 227, 0.68), transparent 34%), linear-gradient(145deg, rgba(238, 232, 190, 0.98), rgba(221, 213, 160, 0.97))",
"--theme-list-text": "#4f4531",
"--theme-list-muted": "rgba(79, 69, 49, 0.7)",
"--theme-list-border": "rgba(105, 93, 67, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 251, 232, 0.62)",
"--card-surface": "linear-gradient(180deg, #7a8f63 0%, #5d714b 100%)",
"--card-border": "rgba(231, 243, 219, 0.24)",
"--card-shadow": "0 10px 28px rgba(44, 58, 33, 0.22)",
"--card-text": "rgba(247, 250, 241, 0.92)",
"--card-muted": "rgba(226, 236, 210, 0.76)",
"--helper-card-surface": "linear-gradient(180deg, #d5c3cc 0%, #b898a8 100%)",
"--helper-card-border": "rgba(100, 72, 84, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(76, 54, 64, 0.16)",
"--helper-card-text": "rgba(49, 36, 41, 0.94)",
"--helper-card-muted": "rgba(83, 63, 71, 0.72)",
},
},
{
name: "mist",
label: "Mist",
swatch: "linear-gradient(135deg, #5f7884 0%, #c7d2dd 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #d8e0e7 0%, #c1ccd8 100%)",
"--theme-agent-bg": "#f8fafc",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(225, 233, 241, 0.56), transparent 34%), linear-gradient(180deg, #fbfcfe 0%, #eef3f7 100%)",
"--theme-feed-bg": "#dce3ea",
"--theme-panel-bg": "rgba(239, 244, 248, 0.92)",
"--theme-panel-bg-strong": "rgba(247, 250, 252, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
"--theme-border": "rgba(96, 117, 131, 0.18)",
"--theme-border-strong": "rgba(86, 104, 118, 0.24)",
"--theme-text": "#23303a",
"--theme-text-soft": "#42515f",
"--theme-text-muted": "#6a7b88",
"--theme-accent": "#5f7884",
"--theme-accent-strong": "#46616d",
"--theme-accent-soft": "rgba(120, 146, 160, 0.18)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(18, 28, 36, 0.36)",
"--theme-shadow": "0 8px 18px rgba(34, 48, 58, 0.12)",
"--theme-shadow-strong": "0 18px 36px rgba(34, 48, 58, 0.18)",
"--theme-status-muted": "#5f6b75",
"--theme-status-live": "#16616c",
"--theme-status-warning": "#9a641d",
"--theme-status-danger": "#a13f35",
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #eef4f8 100%)",
"--theme-card-neutral-border": "rgba(137, 160, 175, 0.22)",
"--theme-card-neutral-text": "#1e2934",
"--theme-card-neutral-muted": "#657786",
"--theme-card-neutral-subtle": "#506271",
"--theme-card-warm-bg": "linear-gradient(180deg, #fcfcfb 0%, #eef1ed 100%)",
"--theme-card-warm-border": "rgba(132, 146, 129, 0.2)",
"--theme-card-warm-text": "#283127",
"--theme-card-warm-muted": "#667262",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(231, 240, 236, 0.98), rgba(213, 227, 220, 0.98))",
"--theme-card-success-border": "rgba(102, 132, 118, 0.2)",
"--theme-card-success-text": "#274036",
"--theme-card-success-muted": "rgba(56, 82, 71, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(244, 248, 250, 0.98), rgba(226, 233, 238, 0.97))",
"--theme-task-border": "rgba(98, 117, 130, 0.14)",
"--theme-task-text": "#23303a",
"--theme-task-muted": "#657684",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(56, 72, 84, 0.12)",
"--theme-task-pattern": "rgba(102, 122, 136, 0.03)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(249, 248, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(236, 230, 198, 0.98), rgba(221, 214, 173, 0.97))",
"--theme-list-text": "#4b4334",
"--theme-list-muted": "rgba(75, 67, 52, 0.72)",
"--theme-list-border": "rgba(100, 92, 73, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 234, 0.62)",
"--card-surface": "linear-gradient(180deg, #6d8391 0%, #526774 100%)",
"--card-border": "rgba(228, 236, 241, 0.24)",
"--card-shadow": "0 10px 28px rgba(35, 49, 58, 0.22)",
"--card-text": "rgba(244, 248, 250, 0.92)",
"--card-muted": "rgba(220, 229, 234, 0.76)",
"--helper-card-surface": "linear-gradient(180deg, #d0bfd0 0%, #b49cb6 100%)",
"--helper-card-border": "rgba(91, 73, 96, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(71, 55, 76, 0.16)",
"--helper-card-text": "rgba(43, 35, 47, 0.94)",
"--helper-card-muted": "rgba(77, 63, 83, 0.72)",
},
},
];
export function getTheme(themeName: string | null | undefined): ThemeOption {
return THEME_OPTIONS.find((theme) => theme.name === themeName) || THEME_OPTIONS[0];
}
export function getStoredThemeName(): ThemeName {
if (typeof window === "undefined") return DEFAULT_THEME;
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
const theme = getTheme(stored);
return theme.name;
}
export function applyThemeToDocument(themeName: ThemeName): ThemeOption {
const theme = getTheme(themeName);
if (typeof document === "undefined") return theme;
const root = document.documentElement;
root.dataset.theme = theme.name;
for (const [key, value] of Object.entries(theme.tokens)) {
root.style.setProperty(key, value);
}
return theme;
}

View file

@ -0,0 +1,30 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import {
applyThemeToDocument,
DEFAULT_THEME,
getStoredThemeName,
THEME_OPTIONS,
THEME_STORAGE_KEY,
type ThemeName,
} from "./themes";
export function useThemePreference() {
const [themeName, setThemeName] = useState<ThemeName>(() => {
if (typeof window === "undefined") return DEFAULT_THEME;
return getStoredThemeName();
});
useEffect(() => {
applyThemeToDocument(themeName);
window.localStorage.setItem(THEME_STORAGE_KEY, themeName);
}, [themeName]);
return useMemo(
() => ({
themeName,
themeOptions: THEME_OPTIONS,
setThemeName,
}),
[themeName],
);
}

View file

@ -34,6 +34,23 @@ export interface CardMessageMetadata {
card_selection_label?: string;
card_selection?: JsonValue;
card_live_content?: JsonValue;
context_label?: string;
}
export interface SessionSummary {
chat_id: string;
key: string;
title: string;
preview: string;
created_at: string;
updated_at: string;
message_count: number;
}
export interface SessionHistoryLine {
role: string;
text: string;
timestamp: string;
}
export type ServerMessage =
@ -45,6 +62,7 @@ export type ServerMessage =
is_progress: boolean;
is_tool_hint: boolean;
timestamp: string;
context_label?: string;
}
| {
type: "card";
@ -65,12 +83,31 @@ export type ServerMessage =
created_at: string;
updated_at: string;
}
| {
type: "workbench";
id: string;
chat_id: string;
kind: "text" | "question";
title: string;
content: string;
question: string;
choices: string[];
response_value: string;
slot: string;
template_key: string;
template_state: Record<string, JsonValue>;
context_summary: string;
promotable: boolean;
source_card_id: string;
created_at: string;
updated_at: string;
}
| { type: "error"; error: string }
| { type: "pong" };
export type ClientMessage =
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }
| { type: "command"; command: string }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata; chat_id?: string }
| { type: "command"; command: string; chat_id?: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "ping" };
@ -79,6 +116,7 @@ export interface LogLine {
role: string;
text: string;
timestamp: string;
contextLabel?: string;
}
export interface CardItem {
@ -100,3 +138,22 @@ export interface CardItem {
createdAt: string;
updatedAt: string;
}
export interface WorkbenchItem {
id: string;
chatId: string;
kind: "text" | "question";
title: string;
content: string;
question?: string;
choices?: string[];
responseValue?: string;
slot?: string;
templateKey?: string;
templateState?: Record<string, JsonValue>;
contextSummary?: string;
promotable: boolean;
sourceCardId?: string;
createdAt: string;
updatedAt: string;
}

157
inbox_service.py Normal file
View file

@ -0,0 +1,157 @@
from __future__ import annotations
import importlib.util
import sys
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
NotificationEventFactory = Callable[[dict[str, Any]], dict[str, Any] | None]
RunNanobotTurn = Callable[..., Awaitable[dict[str, Any]]]
class InboxService:
def __init__(self, *, repo_dir: Path, inbox_dir: Path) -> None:
self._repo_dir = repo_dir
self._inbox_dir = inbox_dir
self._module: Any | None = None
def _load_module(self):
if self._module is not None:
return self._module
script_path = self._repo_dir / "scripts" / "inbox_board.py"
if not script_path.exists():
raise RuntimeError(f"missing helper script: {script_path}")
spec = importlib.util.spec_from_file_location("nanobot_web_inbox_board", script_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"failed to load helper script: {script_path}")
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault("nanobot_web_inbox_board", module)
spec.loader.exec_module(module)
module.ensure_inbox(self._inbox_dir)
self._module = module
return module
def list_items(
self,
*,
status_filter: str | None,
kind_filter: str | None,
include_closed: bool,
limit: int | None,
tags: list[str],
) -> list[dict[str, Any]]:
inbox_board = self._load_module()
items = inbox_board.filter_items(
inbox_board.collect_items(self._inbox_dir),
status=status_filter,
kind=kind_filter,
tags=tags,
include_closed=include_closed,
)
if limit is not None:
items = items[:limit]
return [item.to_dict() for item in items]
def open_items(self, limit: int = 4) -> list[dict[str, Any]]:
return self.list_items(
status_filter=None,
kind_filter=None,
include_closed=False,
limit=limit,
tags=[],
)
def capture_item(
self,
*,
title: str,
text: str,
kind: str,
source: str,
due: str,
body: str,
session_id: str,
tags: list[str],
confidence: int | float | None,
) -> dict[str, Any]:
inbox_board = self._load_module()
created_path = inbox_board.create_item(
self._inbox_dir,
title=title.strip(),
kind=kind,
source=source,
confidence=confidence,
suggested_due=due.strip(),
tags=tags,
body=body.strip(),
raw_text=text.strip(),
metadata={"source_session": session_id.strip()} if session_id.strip() else None,
)
return inbox_board.parse_item(created_path).to_dict()
def dismiss_item(self, item: str) -> dict[str, Any]:
inbox_board = self._load_module()
updated_path = inbox_board.dismiss_item(self._inbox_dir, item)
return inbox_board.parse_item(updated_path).to_dict()
async def accept_item_as_task(
self,
*,
item: str,
lane: str,
title: str,
due: str,
body_text: str | None,
tags: list[str] | None,
run_nanobot_turn: RunNanobotTurn,
notification_to_event: NotificationEventFactory,
) -> dict[str, Any]:
inbox_board = self._load_module()
parsed_item = inbox_board.parse_item(inbox_board.resolve_item_path(self._inbox_dir, item))
item_path = str(parsed_item.path)
instruction_lines = [
"This is a UI inbox action. Groom the inbox item into a task if it is actionable.",
f"Inbox item path: {item_path}",
"Use the inbox_board tool and the task_helper_card tool.",
"First inspect the item with action=show.",
"Then improve the title, description, tags, or due date only if the capture supports it.",
"If it is actionable, accept it into the task board.",
"After accepting it, decide whether the task would be easier to execute immediately with a linked helper card.",
"Create a helper card whenever it would clearly reduce friction for the user instead of making them search or prepare things manually.",
"Use these patterns by default when they fit:",
"- watch/learn -> create a watch helper card",
"- read/research -> create a reading/reference helper card",
"- go somewhere -> create a travel/map helper card",
"- buy/order -> create a shopping/product helper card",
"- call/email/reach out -> create an outreach draft helper card",
"If you have enough information to search or enrich the helper card, do that first.",
"If not, still create the fallback helper card from the task so the user has something actionable in the feed.",
"Prefer backlog unless it is clearly committed or in-progress work.",
"Do not invent details and do not ask follow-up questions.",
"Reply with a short confirmation of what you did, including whether you created a helper card.",
]
if title.strip():
instruction_lines.append(f"Title hint from UI: {title.strip()}")
if body_text is not None and body_text.rstrip():
instruction_lines.append(f"Description hint from UI: {body_text.rstrip()}")
if due.strip():
instruction_lines.append(f"Due hint from UI: {due.strip()}")
if tags:
instruction_lines.append(f"Tag hint from UI: {', '.join(str(tag) for tag in tags)}")
if lane and lane != "backlog":
instruction_lines.append(f"Preferred destination lane: {lane}")
return await run_nanobot_turn(
"\n".join(instruction_lines),
chat_id="inbox-groomer",
metadata={
"source": "web-inbox-card",
"ui_action": "accept_task",
"inbox_item": item_path,
},
timeout_seconds=90.0,
notification_to_event=notification_to_event,
)

210
message_pipeline.py Normal file
View file

@ -0,0 +1,210 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from card_store import coerce_card_record, persist_card
from workbench_store import persist_workbench_item, workbench_notification_target
_JSONRPC_VERSION = "2.0"
_TTS_SENTENCE_BREAK_RE = re.compile(r"(?<=[.!?])\s+")
_TTS_CLAUSE_BREAK_RE = re.compile(r"(?<=[,;:])\s+")
_TTS_SEGMENT_TARGET_CHARS = 180
_TTS_SEGMENT_MAX_CHARS = 260
def encode_sse_data(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def context_label_from_message_metadata(metadata: dict[str, Any]) -> str:
return str(metadata.get("context_label", "")).strip()
def _decode_object(raw: str) -> dict[str, Any] | None:
try:
payload = json.loads(raw)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _wrap_tts_words(text: str, max_chars: int) -> list[str]:
words = text.split()
if not words:
return []
chunks: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if len(candidate) <= max_chars:
current = candidate
continue
chunks.append(current)
current = word
chunks.append(current)
return chunks
def chunk_tts_text(text: str) -> list[str]:
normalized = text.replace("\r\n", "\n").strip()
if not normalized:
return []
chunks: list[str] = []
paragraphs = [part.strip() for part in re.split(r"\n{2,}", normalized) if part.strip()]
for paragraph in paragraphs:
compact = re.sub(r"\s+", " ", paragraph).strip()
if not compact:
continue
sentences = [
sentence.strip()
for sentence in _TTS_SENTENCE_BREAK_RE.split(compact)
if sentence.strip()
]
if not sentences:
sentences = [compact]
current = ""
for sentence in sentences:
parts = [sentence]
if len(sentence) > _TTS_SEGMENT_MAX_CHARS:
parts = [
clause.strip()
for clause in _TTS_CLAUSE_BREAK_RE.split(sentence)
if clause.strip()
] or [sentence]
for part in parts:
if len(part) > _TTS_SEGMENT_MAX_CHARS:
if current:
chunks.append(current)
current = ""
chunks.extend(_wrap_tts_words(part, _TTS_SEGMENT_MAX_CHARS))
continue
candidate = part if not current else f"{current} {part}"
if len(candidate) <= _TTS_SEGMENT_TARGET_CHARS:
current = candidate
continue
if current:
chunks.append(current)
current = part
if current:
chunks.append(current)
return chunks or [re.sub(r"\s+", " ", normalized).strip()]
def typed_message_from_gateway_event(event_dict: dict[str, Any]) -> dict[str, Any] | None:
role = str(event_dict.get("role", "")).strip()
text = str(event_dict.get("text", ""))
timestamp = str(event_dict.get("timestamp", ""))
if role == "agent-state":
return {"type": "agent_state", "state": text}
if role in {"nanobot", "nanobot-progress", "nanobot-tool", "system", "user"}:
return {
"type": "message",
"role": role,
"content": text,
"is_progress": role in {"nanobot-progress", "nanobot-tool"},
"is_tool_hint": role == "nanobot-tool",
"timestamp": timestamp,
}
if role == "card":
payload = _decode_object(text)
if payload is None:
return None
if workbench_notification_target(payload):
item = persist_workbench_item(payload)
if item is None:
return None
item["type"] = "workbench"
return item
card = coerce_card_record(payload)
if card is None:
return None
card["type"] = "card"
return card
if role == "workbench":
payload = _decode_object(text)
if payload is None:
return None
item = persist_workbench_item(payload)
if item is None:
return None
item["type"] = "workbench"
return item
return None
def typed_message_from_api_notification(
obj: dict[str, Any], *, default_chat_id: str | None = None
) -> dict[str, Any] | None:
if obj.get("jsonrpc") != _JSONRPC_VERSION or "method" not in obj:
return None
method = str(obj.get("method", "")).strip()
params = obj.get("params", {})
if not isinstance(params, dict):
params = {}
timestamp = datetime.now(timezone.utc).isoformat()
if method == "message":
content = str(params.get("content", ""))
is_progress = bool(params.get("is_progress", False))
is_tool_hint = bool(params.get("is_tool_hint", False))
return {
"type": "message",
"role": "nanobot-tool"
if is_tool_hint
else ("nanobot-progress" if is_progress else "nanobot"),
"content": content,
"is_progress": is_progress,
"is_tool_hint": is_tool_hint,
"timestamp": timestamp,
}
if method == "agent_state":
return {"type": "agent_state", "state": str(params.get("state", "")).strip()}
if method == "card":
if workbench_notification_target(params):
if default_chat_id and not str(params.get("chat_id", "")).strip():
params = {**params, "chat_id": default_chat_id}
persisted = persist_workbench_item(params, default_chat_id=default_chat_id)
if persisted is None:
return None
payload = dict(persisted)
payload["type"] = "workbench"
return payload
if default_chat_id and not str(params.get("chat_id", "")).strip():
params = {**params, "chat_id": default_chat_id}
persisted = persist_card(params)
if persisted is None:
return None
payload = dict(persisted)
payload["type"] = "card"
return payload
if method == "workbench":
if default_chat_id and not str(params.get("chat_id", "")).strip():
params = {**params, "chat_id": default_chat_id}
persisted = persist_workbench_item(params, default_chat_id=default_chat_id)
if persisted is None:
return None
payload = dict(persisted)
payload["type"] = "workbench"
return payload
return None

265
nanobot_api_client.py Normal file
View file

@ -0,0 +1,265 @@
from __future__ import annotations
import asyncio
import json
import os
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, Callable
_JSONRPC_VERSION = "2.0"
_NANOBOT_API_STREAM_LIMIT = 2 * 1024 * 1024
NANOBOT_API_SOCKET = Path(
os.getenv("NANOBOT_API_SOCKET", str(Path.home() / ".nanobot" / "api.sock"))
).expanduser()
class _NanobotApiError(RuntimeError):
def __init__(self, code: int, message: str) -> None:
super().__init__(message)
self.code = code
def _jsonrpc_request(request_id: str, method: str, params: dict[str, Any]) -> dict[str, Any]:
return {
"jsonrpc": _JSONRPC_VERSION,
"id": request_id,
"method": method,
"params": params,
}
def _jsonrpc_notification(method: str, params: dict[str, Any]) -> dict[str, Any]:
return {
"jsonrpc": _JSONRPC_VERSION,
"method": method,
"params": params,
}
async def _open_nanobot_api_socket(
socket_path: Path = NANOBOT_API_SOCKET,
*,
stream_limit: int = _NANOBOT_API_STREAM_LIMIT,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
if not socket_path.exists():
raise RuntimeError(
f"Nanobot API socket not found at {socket_path}. "
"Enable channels.api and start `nanobot gateway`."
)
try:
return await asyncio.open_unix_connection(
path=str(socket_path),
limit=stream_limit,
)
except OSError as exc:
raise RuntimeError(f"failed to connect to Nanobot API socket: {exc}") from exc
async def _send_nanobot_api_request(
method: str,
params: dict[str, Any],
*,
timeout_seconds: float,
socket_path: Path = NANOBOT_API_SOCKET,
stream_limit: int = _NANOBOT_API_STREAM_LIMIT,
) -> Any:
request_id = str(uuid.uuid4())
reader, writer = await _open_nanobot_api_socket(socket_path, stream_limit=stream_limit)
try:
writer.write(
(
json.dumps(_jsonrpc_request(request_id, method, params), ensure_ascii=False) + "\n"
).encode("utf-8")
)
await writer.drain()
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_seconds
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise RuntimeError(f"timed out waiting for Nanobot API response to {method}")
try:
line = await asyncio.wait_for(reader.readline(), timeout=remaining)
except ValueError as exc:
raise RuntimeError(
"Nanobot API response exceeded the configured stream limit"
) from exc
if not line:
raise RuntimeError("Nanobot API socket closed before responding")
try:
message = json.loads(line.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
continue
if not isinstance(message, dict):
continue
if message.get("jsonrpc") != _JSONRPC_VERSION:
continue
if "method" in message:
continue
if str(message.get("id", "")).strip() != request_id:
continue
if "error" in message:
error = message.get("error", {})
if isinstance(error, dict):
raise _NanobotApiError(
int(error.get("code", -32000)),
str(error.get("message", "unknown Nanobot API error")),
)
raise _NanobotApiError(-32000, str(error))
return message.get("result")
finally:
writer.close()
await writer.wait_closed()
async def _run_nanobot_agent_turn(
content: str,
*,
chat_id: str,
metadata: dict[str, Any] | None = None,
timeout_seconds: float = 90.0,
notification_to_event: Callable[[dict[str, Any]], dict[str, Any] | None],
socket_path: Path = NANOBOT_API_SOCKET,
stream_limit: int = _NANOBOT_API_STREAM_LIMIT,
) -> dict[str, Any]:
final_message = ""
saw_activity = False
async for typed_event in _stream_nanobot_agent_turn(
content,
chat_id=chat_id,
metadata=metadata,
timeout_seconds=timeout_seconds,
notification_to_event=notification_to_event,
socket_path=socket_path,
stream_limit=stream_limit,
):
event_type = typed_event.get("type")
if event_type == "message":
saw_activity = True
if (
not bool(typed_event.get("is_progress", False))
and typed_event.get("role") == "nanobot"
):
final_message = str(typed_event.get("content", ""))
continue
if event_type == "agent_state":
saw_activity = True
return {
"status": "ok",
"message": final_message,
"completed": saw_activity,
}
async def _stream_nanobot_agent_turn(
content: str,
*,
chat_id: str,
metadata: dict[str, Any] | None = None,
timeout_seconds: float = 60.0,
idle_grace_seconds: float = 1.0,
notification_to_event: Callable[[dict[str, Any]], dict[str, Any] | None],
socket_path: Path = NANOBOT_API_SOCKET,
stream_limit: int = _NANOBOT_API_STREAM_LIMIT,
) -> AsyncIterator[dict[str, Any]]:
reader, writer = await _open_nanobot_api_socket(socket_path, stream_limit=stream_limit)
saw_final_message = False
saw_activity = False
try:
writer.write(
(
json.dumps(
_jsonrpc_notification(
"message.send",
{
"content": content,
"chat_id": chat_id,
"metadata": metadata or {},
},
),
ensure_ascii=False,
)
+ "\n"
).encode("utf-8")
)
await writer.drain()
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_seconds
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise RuntimeError("Timed out waiting for a Nanobot response.")
timeout = min(remaining, idle_grace_seconds) if saw_final_message else remaining
try:
line = await asyncio.wait_for(reader.readline(), timeout=timeout)
except asyncio.TimeoutError:
if saw_final_message:
break
raise RuntimeError("Timed out waiting for Nanobot to start responding")
except ValueError as exc:
raise RuntimeError(
"Nanobot API response exceeded the configured stream limit"
) from exc
if not line:
break
try:
obj = json.loads(line.decode("utf-8", errors="replace").strip())
except json.JSONDecodeError:
continue
if not isinstance(obj, dict):
continue
typed_event = notification_to_event(obj)
if typed_event is None:
continue
event_type = typed_event.get("type")
if event_type == "message":
saw_activity = True
if (
not bool(typed_event.get("is_progress", False))
and typed_event.get("role") == "nanobot"
):
saw_final_message = True
yield typed_event
if (
saw_final_message
and event_type == "agent_state"
and str(typed_event.get("state", "")).strip().lower() == "idle"
):
break
if not saw_activity:
raise RuntimeError("Timed out waiting for Nanobot to start responding")
finally:
writer.close()
await writer.wait_closed()
NanobotApiError = _NanobotApiError
send_nanobot_api_request = _send_nanobot_api_request
run_nanobot_agent_turn = _run_nanobot_agent_turn
stream_nanobot_agent_turn = _stream_nanobot_agent_turn
__all__ = [
"NANOBOT_API_SOCKET",
"NanobotApiError",
"run_nanobot_agent_turn",
"send_nanobot_api_request",
"stream_nanobot_agent_turn",
]

15
route_helpers.py Normal file
View file

@ -0,0 +1,15 @@
from __future__ import annotations
import json
from fastapi import Request
async def read_json_request(request: Request) -> dict:
try:
payload = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ValueError("request body must be valid JSON") from exc
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
return payload

19
routes/__init__.py Normal file
View file

@ -0,0 +1,19 @@
from .cards import router as cards_router
from .inbox import router as inbox_router
from .messages import router as messages_router
from .rtc import router as rtc_router
from .sessions import router as sessions_router
from .templates import router as templates_router
from .tools import router as tools_router
from .workbench import router as workbench_router
__all__ = [
"cards_router",
"inbox_router",
"messages_router",
"rtc_router",
"sessions_router",
"templates_router",
"tools_router",
"workbench_router",
]

108
routes/cards.py Normal file
View file

@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from card_store import (
delete_card,
load_card,
load_cards,
normalize_card_id,
parse_iso_datetime,
write_card,
)
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/cards")
async def get_cards(request: Request) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
return JSONResponse(load_cards(chat_id))
@router.delete("/cards/{card_id}")
async def delete_card_route(
card_id: str,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
card = load_card(card_id)
delete_card(card_id)
await runtime.publish_cards_changed(card.get("chat_id") if isinstance(card, dict) else None)
return JSONResponse({"status": "ok"})
@router.post("/cards/{card_id}/snooze")
async def snooze_card(
card_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
until_raw = str(payload.get("until", "")).strip()
until_dt = parse_iso_datetime(until_raw)
if until_dt is None:
return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400)
card = load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
card["snooze_until"] = until_dt.isoformat()
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to snooze card"}, status_code=500)
await runtime.publish_cards_changed(persisted.get("chat_id"))
return JSONResponse({"status": "ok", "card": persisted})
@router.post("/cards/{card_id}/state")
async def update_card_state(
card_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
template_state = payload.get("template_state")
if not isinstance(template_state, dict):
return JSONResponse({"error": "template_state must be an object"}, status_code=400)
card = load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
if str(card.get("kind", "")) != "text":
return JSONResponse({"error": "only text cards support template_state"}, status_code=400)
card["template_state"] = template_state
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to update card state"}, status_code=500)
await runtime.publish_cards_changed(persisted.get("chat_id"))
return JSONResponse({"status": "ok", "card": persisted})

169
routes/inbox.py Normal file
View file

@ -0,0 +1,169 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from message_pipeline import typed_message_from_api_notification
from nanobot_api_client import run_nanobot_agent_turn
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/inbox")
async def get_inbox(
request: Request, runtime: WebAppRuntime = Depends(get_runtime)
) -> JSONResponse:
status_filter = str(request.query_params.get("status", "") or "").strip() or None
kind_filter = str(request.query_params.get("kind", "") or "").strip() or None
include_closed = str(request.query_params.get("include_closed", "") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
limit_raw = str(request.query_params.get("limit", "") or "").strip()
try:
limit = max(1, min(int(limit_raw), 50)) if limit_raw else None
except ValueError:
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
tags_raw = request.query_params.getlist("tag")
tags: list[str] = []
for raw in tags_raw:
for part in str(raw).split(","):
part_clean = part.strip()
if part_clean:
tags.append(part_clean)
try:
items = runtime.inbox_service.list_items(
status_filter=status_filter,
kind_filter=kind_filter,
include_closed=include_closed,
limit=limit,
tags=tags,
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to load inbox: {exc}"}, status_code=500)
return JSONResponse({"items": items})
@router.post("/inbox/capture")
async def capture_inbox_item(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "") or "")
text = str(payload.get("text", "") or "")
kind = str(payload.get("kind", "unknown") or "unknown")
source = str(payload.get("source", "web-ui") or "web-ui")
due = str(payload.get("due", "") or "")
body = str(payload.get("body", "") or "")
session_id = str(payload.get("session_id", "") or "")
raw_tags = payload.get("tags", [])
tags = [str(tag) for tag in raw_tags] if isinstance(raw_tags, list) else []
confidence_value = payload.get("confidence")
confidence = confidence_value if isinstance(confidence_value, (int, float)) else None
try:
item = runtime.inbox_service.capture_item(
title=title,
text=text,
kind=kind,
source=source,
due=due,
body=body,
session_id=session_id,
tags=tags,
confidence=confidence,
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to capture inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
return JSONResponse({"status": "ok", "item": item}, status_code=201)
@router.post("/inbox/accept-task")
async def accept_inbox_item_as_task(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = str(payload.get("item", "") or "").strip()
lane = str(payload.get("lane", "backlog") or "backlog").strip()
title = str(payload.get("title", "") or "")
due = str(payload.get("due", "") or "")
body = payload.get("body")
body_text = str(body) if isinstance(body, str) else None
raw_tags = payload.get("tags")
tags = [str(tag) for tag in raw_tags] if isinstance(raw_tags, list) else None
if not item:
return JSONResponse({"error": "item is required"}, status_code=400)
try:
result = await runtime.inbox_service.accept_item_as_task(
item=item,
lane=lane,
title=title,
due=due,
body_text=body_text,
tags=tags,
run_nanobot_turn=run_nanobot_agent_turn,
notification_to_event=lambda obj: typed_message_from_api_notification(
obj,
default_chat_id="inbox-groomer",
),
)
except (FileNotFoundError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except Exception as exc:
return JSONResponse({"error": f"failed to accept inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
await runtime.publish_cards_changed()
return JSONResponse(result)
@router.post("/inbox/dismiss")
async def dismiss_inbox_item(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = str(payload.get("item", "") or "").strip()
if not item:
return JSONResponse({"error": "item is required"}, status_code=400)
try:
item_payload = runtime.inbox_service.dismiss_item(item)
except (FileNotFoundError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to dismiss inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
return JSONResponse({"status": "ok", "item": item_payload})

114
routes/messages.py Normal file
View file

@ -0,0 +1,114 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import (
context_label_from_message_metadata,
encode_sse_data,
typed_message_from_api_notification,
)
from nanobot_api_client import stream_nanobot_agent_turn
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
router = APIRouter()
@router.post("/message")
async def post_message(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
text = str(payload.get("text", "")).strip()
metadata = payload.get("metadata", {})
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not text:
return JSONResponse({"error": "empty message"}, status_code=400)
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not isinstance(metadata, dict):
metadata = {}
try:
await runtime.gateway.send_user_message(text, metadata=metadata, chat_id=chat_id)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"status": "ok"})
@router.post("/message/stream")
async def stream_message(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
):
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
text = str(payload.get("text", "")).strip()
metadata = payload.get("metadata", {})
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not text:
return JSONResponse({"error": "empty message"}, status_code=400)
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not isinstance(metadata, dict):
metadata = {}
context_label = context_label_from_message_metadata(metadata)
async def stream_turn():
try:
yield ": stream-open\n\n"
yield encode_sse_data(
{
"type": "message",
"role": "user",
"content": text,
"is_progress": False,
"is_tool_hint": False,
"timestamp": datetime.now(timezone.utc).isoformat(),
"context_label": context_label or None,
}
)
async for typed_event in stream_nanobot_agent_turn(
text,
chat_id=chat_id,
metadata=metadata,
timeout_seconds=60.0,
notification_to_event=lambda obj: typed_message_from_api_notification(
obj,
default_chat_id=chat_id,
),
):
if typed_event.get("type") == "card":
await runtime.publish_cards_changed(
typed_event.get("chat_id") if isinstance(typed_event, dict) else chat_id
)
elif typed_event.get("type") == "workbench":
await runtime.publish_workbench_changed(
typed_event.get("chat_id") if isinstance(typed_event, dict) else chat_id
)
yield encode_sse_data(typed_event)
await runtime.publish_sessions_changed()
except RuntimeError as exc:
yield encode_sse_data({"type": "error", "error": str(exc)})
return StreamingResponse(
stream_turn(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

29
routes/rtc.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.post("/rtc/offer")
async def rtc_offer(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
answer = await runtime.rtc_manager.handle_offer(payload)
if answer is None:
return JSONResponse(
{"error": "WebRTC backend unavailable on host (aiortc is not installed)."},
status_code=503,
)
return JSONResponse(answer)

132
routes/sessions.py Normal file
View file

@ -0,0 +1,132 @@
from __future__ import annotations
import asyncio
import uuid
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import encode_sse_data
from route_helpers import read_json_request
from session_store import (
create_session,
delete_session,
is_web_session_chat_id,
list_web_sessions,
load_session_payload,
normalize_session_chat_id,
rename_session,
)
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/events")
async def stream_ui_events(request: Request, runtime: WebAppRuntime = Depends(get_runtime)):
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
subscription_id, queue = await runtime.event_bus.subscribe(chat_id)
async def stream_events():
try:
yield ": stream-open\n\n"
yield encode_sse_data({"type": "events.ready", "chat_id": chat_id})
while True:
if await request.is_disconnected():
break
try:
payload = await asyncio.wait_for(queue.get(), timeout=20.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield encode_sse_data(payload)
finally:
await runtime.event_bus.unsubscribe(subscription_id)
return StreamingResponse(
stream_events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions")
async def get_sessions() -> JSONResponse:
return JSONResponse({"sessions": list_web_sessions()})
@router.get("/sessions/{chat_id}")
async def get_session(chat_id: str) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
payload = load_session_payload(session_chat_id)
if payload is None:
return JSONResponse({"error": "session not found"}, status_code=404)
return JSONResponse(payload)
@router.post("/sessions")
async def create_session_route(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "")).strip()
chat_id = f"web-{uuid.uuid4().hex[:8]}"
summary = create_session(chat_id, title=title)
await runtime.publish_sessions_changed()
return JSONResponse({"session": summary}, status_code=201)
@router.patch("/sessions/{chat_id}")
async def rename_session_route(
chat_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "")).strip()
summary = rename_session(session_chat_id, title)
if summary is None:
return JSONResponse({"error": "session not found"}, status_code=404)
await runtime.publish_sessions_changed()
return JSONResponse({"session": summary})
@router.delete("/sessions/{chat_id}")
async def delete_session_route(
chat_id: str,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
if not delete_session(session_chat_id, runtime.delete_cards_for_chat):
return JSONResponse({"error": "session not found"}, status_code=404)
await runtime.publish_sessions_changed()
await runtime.publish_cards_changed()
return JSONResponse({"status": "ok", "chat_id": session_chat_id})

92
routes/templates.py Normal file
View file

@ -0,0 +1,92 @@
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from card_store import (
list_templates,
normalize_template_key,
read_template_meta,
sync_templates_context_file,
template_dir,
template_html_path,
template_meta_path,
)
from route_helpers import read_json_request
router = APIRouter()
@router.get("/templates")
async def get_templates() -> JSONResponse:
return JSONResponse(list_templates())
@router.post("/templates")
async def save_template(request: Request) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
key = normalize_template_key(str(payload.get("key", "")))
title = str(payload.get("title", "")).strip()
content = str(payload.get("content", "")).strip()
notes = str(payload.get("notes", "")).strip()
example_state = payload.get("example_state", {})
if not isinstance(example_state, dict):
example_state = {}
if not key:
return JSONResponse({"error": "template key is required"}, status_code=400)
if not content:
return JSONResponse({"error": "template content is required"}, status_code=400)
current_template_dir = template_dir(key)
current_template_dir.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc).isoformat()
existing_meta = read_template_meta(key)
created_at = str(existing_meta.get("created_at") or now)
template_html_path(key).write_text(content, encoding="utf-8")
template_meta_path(key).write_text(
json.dumps(
{
"key": key,
"title": title,
"notes": notes,
"example_state": example_state,
"created_at": created_at,
"updated_at": now,
},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
sync_templates_context_file()
return JSONResponse(
{
"status": "ok",
"id": key,
"key": key,
"example_state": example_state,
"file_url": f"/card-templates/{key}/template.html",
}
)
@router.delete("/templates/{template_key}")
async def delete_template(template_key: str) -> JSONResponse:
key = normalize_template_key(template_key)
if not key:
return JSONResponse({"error": "invalid template key"}, status_code=400)
shutil.rmtree(template_dir(key), ignore_errors=True)
sync_templates_context_file()
return JSONResponse({"status": "ok", "key": key})

138
routes/tools.py Normal file
View file

@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import encode_sse_data
from nanobot_api_client import NanobotApiError, send_nanobot_api_request
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/tools")
async def list_tools() -> JSONResponse:
try:
result = await send_nanobot_api_request("tool.list", {}, timeout_seconds=20.0)
except NanobotApiError as exc:
status_code = 503 if exc.code == -32000 else 502
return JSONResponse({"error": str(exc)}, status_code=status_code)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
if not isinstance(result, dict):
return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
tools = result.get("tools", [])
if not isinstance(tools, list):
return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
return JSONResponse({"tools": tools})
@router.post("/tools/call")
async def call_tool(
request: Request, runtime: WebAppRuntime = Depends(get_runtime)
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
tool_name = str(payload.get("tool_name", payload.get("name", ""))).strip()
if not tool_name:
return JSONResponse({"error": "tool_name is required"}, status_code=400)
arguments = payload.get("arguments", payload.get("params", {}))
if arguments is None:
arguments = {}
if not isinstance(arguments, dict):
return JSONResponse({"error": "arguments must be a JSON object"}, status_code=400)
async_requested = payload.get("async") is True
if async_requested:
job_payload = await runtime.tool_job_service.start_job(tool_name, arguments)
return JSONResponse(job_payload, status_code=202)
try:
result = await send_nanobot_api_request(
"tool.call",
{"name": tool_name, "arguments": arguments},
timeout_seconds=60.0,
)
except NanobotApiError as exc:
status_code = 400 if exc.code == -32602 else 503 if exc.code == -32000 else 502
return JSONResponse({"error": str(exc)}, status_code=status_code)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
if not isinstance(result, dict):
return JSONResponse(
{"error": "Nanobot API returned an invalid tool response"}, status_code=502
)
return JSONResponse(result)
@router.get("/tools/jobs/{job_id}")
async def get_tool_job(job_id: str, runtime: WebAppRuntime = Depends(get_runtime)) -> JSONResponse:
safe_job_id = job_id.strip()
if not safe_job_id:
return JSONResponse({"error": "job id is required"}, status_code=400)
payload = await runtime.tool_job_service.get_job(safe_job_id)
if payload is None:
return JSONResponse({"error": "tool job not found"}, status_code=404)
return JSONResponse(payload)
@router.get("/tools/jobs/{job_id}/stream")
async def stream_tool_job(
job_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
):
safe_job_id = job_id.strip()
if not safe_job_id:
return JSONResponse({"error": "job id is required"}, status_code=400)
subscription = await runtime.tool_job_service.subscribe_job(safe_job_id)
if subscription is None:
return JSONResponse({"error": "tool job not found"}, status_code=404)
subscription_id, queue = subscription
current = await runtime.tool_job_service.get_job(safe_job_id)
if current is None:
await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id)
return JSONResponse({"error": "tool job not found"}, status_code=404)
async def stream_events():
try:
yield ": stream-open\n\n"
yield encode_sse_data({"type": "tool.job", "job": current})
if current.get("status") in {"completed", "failed"}:
return
while True:
if await request.is_disconnected():
break
try:
payload = await asyncio.wait_for(queue.get(), timeout=20.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield encode_sse_data({"type": "tool.job", "job": payload})
if payload.get("status") in {"completed", "failed"}:
break
finally:
await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id)
return StreamingResponse(
stream_events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

103
routes/workbench.py Normal file
View file

@ -0,0 +1,103 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from card_store import normalize_card_id
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
from workbench_store import (
delete_workbench_item,
load_workbench_items,
persist_workbench_item,
promote_workbench_item,
)
router = APIRouter()
@router.get("/workbench")
async def get_workbench(request: Request) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
try:
items = load_workbench_items(chat_id)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to load workbench: {exc}"}, status_code=500)
return JSONResponse({"items": items})
@router.post("/workbench")
async def upsert_workbench(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = persist_workbench_item(payload)
if item is None:
return JSONResponse({"error": "invalid workbench payload"}, status_code=400)
await runtime.publish_workbench_changed(item.get("chat_id"))
return JSONResponse({"status": "ok", "item": item}, status_code=201)
@router.delete("/workbench/{item_id}")
async def delete_workbench(
item_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not normalize_card_id(item_id):
return JSONResponse({"error": "invalid workbench item id"}, status_code=400)
try:
removed = delete_workbench_item(chat_id, item_id)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to delete workbench item: {exc}"}, status_code=500)
if not removed:
return JSONResponse({"error": "workbench item not found"}, status_code=404)
await runtime.publish_workbench_changed(chat_id)
return JSONResponse({"status": "ok", "item_id": item_id})
@router.post("/workbench/{item_id}/promote")
async def promote_workbench(
item_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError:
payload = {}
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not normalize_card_id(item_id):
return JSONResponse({"error": "invalid workbench item id"}, status_code=400)
try:
card = promote_workbench_item(chat_id, item_id)
except FileNotFoundError:
return JSONResponse({"error": "workbench item not found"}, status_code=404)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
except Exception as exc:
return JSONResponse({"error": f"failed to promote workbench item: {exc}"}, status_code=500)
await runtime.publish_workbench_changed(chat_id)
await runtime.publish_cards_changed(card.get("chat_id"))
return JSONResponse({"status": "ok", "card": card, "item_id": item_id})

109
rtc_manager.py Normal file
View file

@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable
from typing import Any
from card_store import persist_card
from message_pipeline import chunk_tts_text, typed_message_from_gateway_event
from voice_rtc import WebRTCVoiceSession
PublishCallback = Callable[[Any], Awaitable[None]]
class RtcSessionManager:
def __init__(
self,
*,
gateway,
publish_cards_changed: PublishCallback,
publish_workbench_changed: PublishCallback,
publish_sessions_changed: Callable[[], Awaitable[None]],
) -> None:
self._gateway = gateway
self._publish_cards_changed = publish_cards_changed
self._publish_workbench_changed = publish_workbench_changed
self._publish_sessions_changed = publish_sessions_changed
self._active_session: WebRTCVoiceSession | None = None
self._active_queue: asyncio.Queue | None = None
self._sender_task: asyncio.Task | None = None
async def handle_offer(self, payload: dict[str, Any]) -> dict[str, Any] | None:
await self._close_active_session()
queue = await self._gateway.subscribe()
self._active_queue = queue
voice_session = WebRTCVoiceSession(gateway=self._gateway)
self._active_session = voice_session
self._sender_task = asyncio.create_task(
self._sender_loop(queue, voice_session),
name="rtc-sender",
)
answer = await voice_session.handle_offer(payload)
if answer is None:
await self._close_active_session()
return None
await self._gateway.connect_nanobot()
return answer
async def shutdown(self) -> None:
await self._close_active_session()
async def _close_active_session(self) -> None:
if self._sender_task is not None:
self._sender_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._sender_task
self._sender_task = None
if self._active_session is not None:
await self._active_session.close()
self._active_session = None
if self._active_queue is not None:
await self._gateway.unsubscribe(self._active_queue)
self._active_queue = None
async def _sender_loop(
self,
queue: asyncio.Queue,
voice_session: WebRTCVoiceSession,
) -> None:
while True:
event = await queue.get()
if event.role == "nanobot-tts-partial":
await voice_session.queue_output_text(event.text, partial=True)
continue
if event.role == "nanobot-tts-flush":
await voice_session.flush_partial_output_text()
continue
if event.role == "nanobot-tts":
for segment in chunk_tts_text(event.text):
await voice_session.queue_output_text(segment)
continue
typed_event = typed_message_from_gateway_event(event.to_dict())
if typed_event is None:
continue
if typed_event.get("type") == "card":
persisted = persist_card(typed_event)
if persisted is None:
continue
payload = dict(persisted)
payload["type"] = "card"
await self._publish_cards_changed(payload.get("chat_id"))
voice_session.send_to_datachannel(payload)
continue
if typed_event.get("type") == "workbench":
await self._publish_workbench_changed(typed_event.get("chat_id"))
voice_session.send_to_datachannel(typed_event)
continue
if (
typed_event.get("type") == "message"
and not bool(typed_event.get("is_progress", False))
and typed_event.get("role") == "nanobot"
):
await self._publish_sessions_changed()
voice_session.send_to_datachannel(typed_event)

View file

@ -0,0 +1,24 @@
export function mount({ root, state, host }) {
const render = (nextState) => {
const title = typeof nextState.title === "string" ? nextState.title : "";
root.innerHTML = `<div data-fixture-title="${title}">${title}</div>`;
};
render(state);
host.setLiveContent({ phase: "mount", title: state.title ?? "" });
host.setRefreshHandler(() => {
root.dataset.refreshed = "1";
});
return {
update({ state: nextState, host: nextHost }) {
render(nextState);
nextHost.setLiveContent({ phase: "update", title: nextState.title ?? "" });
},
destroy() {
root.dataset.destroyed = "1";
root.innerHTML = "";
host.setRefreshHandler(null);
},
};
}

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
LIVE_TEMPLATES_DIR = Path.home() / ".nanobot" / "cards" / "templates"
EXAMPLE_TEMPLATES_DIR = (
Path(__file__).resolve().parent.parent / "examples" / "cards" / "templates"
)
LEGACY_MARKERS = (
"__nanobot",
"document.currentScript",
"mountLegacyTemplate",
"legacy-template-module",
)
def iter_template_dirs(root: Path) -> dict[str, Path]:
templates: dict[str, Path] = {}
if not root.exists():
return templates
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if not (child / "manifest.json").exists():
continue
templates[child.name] = child
return templates
def validate_template_dir(template_dir: Path, failures: list[str]) -> None:
manifest_path = template_dir / "manifest.json"
template_path = template_dir / "template.html"
runtime_path = template_dir / "card.js"
for required in (manifest_path, template_path, runtime_path):
if not required.exists():
failures.append(f"{template_dir}: missing {required.name}")
return
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as exc:
failures.append(f"{manifest_path}: invalid JSON ({exc})")
return
if not isinstance(manifest, dict):
failures.append(f"{manifest_path}: manifest must be an object")
template_html = template_path.read_text(encoding="utf-8")
if "<script" in template_html.lower():
failures.append(f"{template_path}: inline script tags are not allowed")
runtime_js = runtime_path.read_text(encoding="utf-8")
if "export function mount" not in runtime_js:
failures.append(f"{runtime_path}: missing `export function mount`")
for marker in LEGACY_MARKERS:
if marker in runtime_js:
failures.append(f"{runtime_path}: legacy marker `{marker}` still present")
result = subprocess.run(
["node", "--check", str(runtime_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
failures.append(f"{runtime_path}: node --check failed: {detail}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--live-root", type=Path, default=LIVE_TEMPLATES_DIR)
parser.add_argument("--example-root", type=Path, default=EXAMPLE_TEMPLATES_DIR)
args = parser.parse_args()
live_root = args.live_root.expanduser()
example_root = args.example_root.expanduser()
failures: list[str] = []
for template_dir in iter_template_dirs(live_root).values():
validate_template_dir(template_dir, failures)
for template_dir in iter_template_dirs(example_root).values():
validate_template_dir(template_dir, failures)
sync_check = subprocess.run(
[
"python3",
str(Path(__file__).resolve().parent / "sync_card_templates.py"),
"--check",
"--source",
str(live_root),
"--dest",
str(example_root),
],
capture_output=True,
text=True,
)
if sync_check.returncode != 0:
detail = (sync_check.stdout + sync_check.stderr).strip()
if detail:
print(detail)
failures.append("template mirror drift detected")
if failures:
for failure in failures:
print(failure)
return 1
print("card runtime ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { mount } from "./card_runtime_fixture_card.mjs";
function createFakeRoot() {
return {
dataset: {},
innerHTML: "",
};
}
async function main() {
const root = createFakeRoot();
const liveContentCalls = [];
const refreshHandlers = [];
const host = {
setLiveContent(value) {
liveContentCalls.push(value);
},
setRefreshHandler(handler) {
refreshHandlers.push(handler);
},
};
const mounted = mount({
root,
state: { title: "alpha" },
host,
});
assert.equal(typeof mounted?.update, "function");
assert.equal(typeof mounted?.destroy, "function");
assert.match(root.innerHTML, /alpha/);
assert.deepEqual(liveContentCalls.at(-1), { phase: "mount", title: "alpha" });
assert.equal(typeof refreshHandlers.at(-1), "function");
refreshHandlers.at(-1)?.();
assert.equal(root.dataset.refreshed, "1");
mounted.update({
root,
state: { title: "beta" },
host,
});
assert.match(root.innerHTML, /beta/);
assert.deepEqual(liveContentCalls.at(-1), { phase: "update", title: "beta" });
mounted.destroy();
assert.equal(root.innerHTML, "");
assert.equal(root.dataset.destroyed, "1");
assert.equal(refreshHandlers.at(-1), null);
console.log("card runtime fixture ok");
}
await main();

View file

@ -5,6 +5,19 @@ status=0
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python_files=(
"app.py"
"app_dependencies.py"
"card_store.py"
"inbox_service.py"
"message_pipeline.py"
"nanobot_api_client.py"
"route_helpers.py"
"rtc_manager.py"
"session_store.py"
"tool_job_service.py"
"ui_event_bus.py"
"web_runtime.py"
"workbench_store.py"
"routes"
)
run_check() {
@ -33,7 +46,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \
--requirements-files requirements.txt \
--known-first-party app,supertonic_gateway,voice_rtc,wisper \
--known-first-party app,card_store,supertonic_gateway,sync_card_templates,voice_rtc,wisper \
--per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \
@ -42,5 +55,14 @@ run_check \
"Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \
vulture "${python_files[@]}" --min-confidence 80
run_check \
"Card Runtime" \
python3 scripts/check_card_runtime.py
run_check \
"Card Runtime Fixture" \
node scripts/check_card_runtime_fixture.mjs
run_check \
"Backend Tests" \
.venv/bin/python -m unittest discover -s tests -p "test_*.py"
exit "${status}"

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import filecmp
import os
import shutil
from pathlib import Path
LIVE_TEMPLATES_DIR = Path.home() / ".nanobot" / "cards" / "templates"
EXAMPLE_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "examples" / "cards" / "templates"
def iter_template_dirs(root: Path) -> dict[str, Path]:
templates: dict[str, Path] = {}
if not root.exists():
return templates
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if not (child / "manifest.json").exists():
continue
templates[child.name] = child
return templates
def sync_directory(source: Path, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
source_entries = {path.name: path for path in source.iterdir()}
dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {}
for name, source_path in source_entries.items():
dest_path = dest / name
if source_path.is_dir():
sync_directory(source_path, dest_path)
continue
dest_path.parent.mkdir(parents=True, exist_ok=True)
if not dest_path.exists() or not filecmp.cmp(source_path, dest_path, shallow=False):
shutil.copy2(source_path, dest_path)
for name, dest_path in dest_entries.items():
if name in source_entries:
continue
if dest_path.is_dir():
shutil.rmtree(dest_path)
else:
dest_path.unlink(missing_ok=True)
def compare_directory(source: Path, dest: Path, drifts: list[str], *, prefix: str) -> None:
source_entries = {path.name: path for path in source.iterdir()} if source.exists() else {}
dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {}
for name, source_path in source_entries.items():
rel = f"{prefix}/{name}" if prefix else name
dest_path = dest / name
if name not in dest_entries:
drifts.append(f"missing mirror: {rel}")
continue
if source_path.is_dir():
if not dest_path.is_dir():
drifts.append(f"type mismatch: {rel}")
continue
compare_directory(source_path, dest_path, drifts, prefix=rel)
continue
if dest_path.is_dir():
drifts.append(f"type mismatch: {rel}")
continue
if not filecmp.cmp(source_path, dest_path, shallow=False):
drifts.append(f"content drift: {rel}")
for name in dest_entries:
if name not in source_entries:
rel = f"{prefix}/{name}" if prefix else name
drifts.append(f"stale mirror: {rel}")
def run_check(source_root: Path, dest_root: Path) -> int:
drifts: list[str] = []
source_templates = iter_template_dirs(source_root)
dest_templates = iter_template_dirs(dest_root)
for name, source_dir in source_templates.items():
dest_dir = dest_root / name
if name not in dest_templates:
drifts.append(f"missing mirror: {name}")
continue
compare_directory(source_dir, dest_dir, drifts, prefix=name)
for name in dest_templates:
if name not in source_templates:
drifts.append(f"stale mirror: {name}")
if drifts:
for drift in drifts:
print(drift)
return 1
print("template sync ok")
return 0
def run_sync(source_root: Path, dest_root: Path) -> int:
source_templates = iter_template_dirs(source_root)
dest_root.mkdir(parents=True, exist_ok=True)
for name, source_dir in source_templates.items():
sync_directory(source_dir, dest_root / name)
for child in list(dest_root.iterdir()):
if child.name in source_templates:
continue
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink(missing_ok=True)
print(f"synced {len(source_templates)} template directories")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
parser.add_argument("--source", type=Path, default=LIVE_TEMPLATES_DIR)
parser.add_argument("--dest", type=Path, default=EXAMPLE_TEMPLATES_DIR)
args = parser.parse_args()
source_root = args.source.expanduser()
dest_root = args.dest.expanduser()
if args.check:
return run_check(source_root, dest_root)
return run_sync(source_root, dest_root)
if __name__ == "__main__":
raise SystemExit(main())

317
session_store.py Normal file
View file

@ -0,0 +1,317 @@
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from card_store import NANOBOT_WORKSPACE
NANOBOT_RUNTIME_WORKSPACE = Path(
os.getenv(
"NANOBOT_RUNTIME_WORKSPACE",
str(
(NANOBOT_WORKSPACE / "workspace")
if (NANOBOT_WORKSPACE / "workspace").exists()
else NANOBOT_WORKSPACE
),
)
).expanduser()
SESSIONS_DIR = NANOBOT_RUNTIME_WORKSPACE / "sessions"
_SESSION_CHAT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
def ensure_session_store_dirs() -> None:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
def _normalize_session_chat_id(raw: str) -> str:
chat_id = raw.strip()
return chat_id if _SESSION_CHAT_ID_PATTERN.fullmatch(chat_id) else ""
def _is_web_session_chat_id(chat_id: str) -> bool:
return chat_id == "web" or chat_id.startswith("web-")
def _session_key(chat_id: str) -> str:
return f"api:{chat_id}"
def _session_path(chat_id: str) -> Path:
return SESSIONS_DIR / f"api_{chat_id}.jsonl"
def _session_summary_from_messages(
chat_id: str,
*,
created_at: str,
updated_at: str,
metadata: dict[str, Any],
messages: list[dict[str, Any]],
) -> dict[str, Any]:
title = str(metadata.get("title", "")).strip()
preview = ""
for message in messages:
if message.get("role") == "user":
text = str(message.get("content", "")).strip()
if text:
if not title:
title = text
break
for message in reversed(messages):
role = str(message.get("role", "")).strip()
text = str(message.get("content", "")).strip()
if role in {"user", "assistant", "system"} and text:
preview = text
break
if not title:
title = "New conversation"
return {
"chat_id": chat_id,
"key": _session_key(chat_id),
"title": title[:120],
"preview": preview[:240],
"created_at": created_at,
"updated_at": updated_at,
"message_count": len(messages),
}
def _load_session_file(chat_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
ensure_session_store_dirs()
path = _session_path(chat_id)
if not path.exists():
return None
metadata: dict[str, Any] = {
"key": _session_key(chat_id),
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"metadata": {},
}
messages: list[dict[str, Any]] = []
try:
with path.open(encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(payload, dict):
continue
if payload.get("_type") == "metadata":
metadata = payload
continue
messages.append(payload)
except OSError:
return None
return metadata, messages
def _serialize_session_messages(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for message in messages:
role = str(message.get("role", "")).strip().lower()
content = str(message.get("content", "")).strip()
timestamp = str(message.get("timestamp", "")).strip()
if role not in {"user", "assistant", "tool", "system"}:
continue
if not content:
continue
rows.append(
{
"role": "nanobot" if role == "assistant" else role,
"text": content,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
}
)
return rows
def _load_session_summary(chat_id: str) -> dict[str, Any] | None:
loaded = _load_session_file(chat_id)
if loaded is None:
return None
metadata, messages = loaded
meta_dict = metadata.get("metadata", {})
return _session_summary_from_messages(
chat_id,
created_at=str(metadata.get("created_at", "")),
updated_at=str(metadata.get("updated_at", "")),
metadata=meta_dict if isinstance(meta_dict, dict) else {},
messages=messages,
)
def _empty_web_session_payload() -> dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
return {
"session": {
"chat_id": "web",
"key": _session_key("web"),
"title": "Current conversation",
"preview": "",
"created_at": now,
"updated_at": now,
"message_count": 0,
},
"messages": [],
}
def _load_session_payload(chat_id: str) -> dict[str, Any] | None:
loaded = _load_session_file(chat_id)
if loaded is None:
return _empty_web_session_payload() if chat_id == "web" else None
metadata, messages = loaded
meta_dict = metadata.get("metadata", {})
summary = _session_summary_from_messages(
chat_id,
created_at=str(metadata.get("created_at", "")),
updated_at=str(metadata.get("updated_at", "")),
metadata=meta_dict if isinstance(meta_dict, dict) else {},
messages=messages,
)
return {"session": summary, "messages": _serialize_session_messages(messages)}
def _list_web_sessions() -> list[dict[str, Any]]:
ensure_session_store_dirs()
sessions: list[dict[str, Any]] = []
seen_chat_ids: set[str] = set()
for path in sorted(SESSIONS_DIR.glob("api_*.jsonl")):
chat_id = path.stem[4:]
if not _is_web_session_chat_id(chat_id):
continue
summary = _load_session_summary(chat_id)
if summary is None:
continue
sessions.append(summary)
seen_chat_ids.add(chat_id)
if "web" not in seen_chat_ids:
now = datetime.now(timezone.utc).isoformat()
sessions.append(
{
"chat_id": "web",
"key": _session_key("web"),
"title": "Current conversation",
"preview": "",
"created_at": now,
"updated_at": now,
"message_count": 0,
}
)
sessions.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
return sessions
def _create_session(chat_id: str, title: str = "") -> dict[str, Any]:
ensure_session_store_dirs()
now = datetime.now(timezone.utc).isoformat()
payload = {
"_type": "metadata",
"key": _session_key(chat_id),
"created_at": now,
"updated_at": now,
"metadata": {"title": title.strip()} if title.strip() else {},
"last_consolidated": 0,
}
_session_path(chat_id).write_text(
json.dumps(payload, ensure_ascii=False) + "\n", encoding="utf-8"
)
summary = _load_session_summary(chat_id)
if summary is None:
raise RuntimeError("failed to create session")
return summary
def _write_session_file(
chat_id: str, metadata: dict[str, Any], messages: list[dict[str, Any]]
) -> dict[str, Any] | None:
try:
ensure_session_store_dirs()
encoded_lines = [json.dumps(metadata, ensure_ascii=False)]
encoded_lines.extend(json.dumps(message, ensure_ascii=False) for message in messages)
_session_path(chat_id).write_text("\n".join(encoded_lines) + "\n", encoding="utf-8")
except OSError:
return None
return _load_session_summary(chat_id)
def _rename_session(chat_id: str, title: str) -> dict[str, Any] | None:
loaded = _load_session_file(chat_id)
if loaded is None:
if chat_id != "web":
return None
return _create_session(chat_id, title=title)
metadata, messages = loaded
meta_dict = metadata.get("metadata", {})
if not isinstance(meta_dict, dict):
meta_dict = {}
if title.strip():
meta_dict["title"] = title.strip()
else:
meta_dict.pop("title", None)
now = datetime.now(timezone.utc).isoformat()
normalized_metadata = {
**metadata,
"_type": "metadata",
"key": _session_key(chat_id),
"created_at": str(metadata.get("created_at", "") or now),
"updated_at": now,
"metadata": meta_dict,
"last_consolidated": metadata.get("last_consolidated", 0),
}
return _write_session_file(chat_id, normalized_metadata, messages)
def _delete_session(chat_id: str, delete_cards_for_chat: Callable[[str], int]) -> bool:
path = _session_path(chat_id)
deleted_file = False
if path.exists():
try:
path.unlink()
deleted_file = True
except OSError:
return False
deleted_cards = delete_cards_for_chat(chat_id)
return deleted_file or deleted_cards > 0 or chat_id == "web"
normalize_session_chat_id = _normalize_session_chat_id
is_web_session_chat_id = _is_web_session_chat_id
list_web_sessions = _list_web_sessions
load_session_payload = _load_session_payload
create_session = _create_session
rename_session = _rename_session
delete_session = _delete_session
__all__ = [
"SESSIONS_DIR",
"create_session",
"delete_session",
"ensure_session_store_dirs",
"is_web_session_chat_id",
"list_web_sessions",
"load_session_payload",
"normalize_session_chat_id",
"rename_session",
]

View file

@ -127,7 +127,13 @@ class NanobotApiProcess:
self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader")
await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot."))
async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None:
async def send(
self,
text: str,
metadata: dict[str, Any] | None = None,
*,
chat_id: str = "web",
) -> None:
if not self.running or self._writer is None:
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
raise RuntimeError("Not connected to nanobot.")
@ -136,7 +142,7 @@ class NanobotApiProcess:
"message.send",
{
"content": text,
"chat_id": "web",
"chat_id": chat_id,
"metadata": dict(metadata or {}),
},
)
@ -161,7 +167,7 @@ class NanobotApiProcess:
await self._cleanup()
raise RuntimeError(f"Send failed: {exc}") from exc
async def send_command(self, command: str) -> None:
async def send_command(self, command: str, *, chat_id: str = "web") -> None:
if not self.running or self._writer is None:
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
raise RuntimeError("Not connected to nanobot.")
@ -170,7 +176,7 @@ class NanobotApiProcess:
"command.execute",
{
"command": command,
"chat_id": "web",
"chat_id": chat_id,
},
)
except OSError as exc:
@ -377,24 +383,30 @@ class SuperTonicGateway:
raise RuntimeError("Not connected to nanobot.")
return self._process
async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> None:
async def send_user_message(
self,
text: str,
metadata: dict[str, Any] | None = None,
*,
chat_id: str = "web",
) -> None:
message = text.strip()
if not message:
return
await self.bus.publish(WisperEvent(role="user", text=message))
async with self._lock:
process = await self._ensure_connected_process()
await process.send(message, metadata=metadata)
await process.send(message, metadata=metadata, chat_id=chat_id)
async def send_card_response(self, card_id: str, value: str) -> None:
async with self._lock:
process = await self._ensure_connected_process()
await process.send_card_response(card_id, value)
async def send_command(self, command: str) -> None:
async def send_command(self, command: str, *, chat_id: str = "web") -> None:
async with self._lock:
process = await self._ensure_connected_process()
await process.send_command(command)
await process.send_command(command, chat_id=chat_id)
async def disconnect_nanobot(self) -> None:
async with self._lock:

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from routes.sessions import stream_ui_events
from ui_event_bus import UiEventBus
class DummyRequest:
def __init__(self, chat_id: str) -> None:
self.query_params = {"chat_id": chat_id}
async def is_disconnected(self) -> bool:
return False
class EventsRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_events_stream_emits_ready_and_published_payload(self) -> None:
runtime = SimpleNamespace(event_bus=UiEventBus())
response = await stream_ui_events(DummyRequest("test-chat"), runtime=runtime)
iterator = response.body_iterator
first_chunk = await anext(iterator)
self.assertEqual(first_chunk, ": stream-open\n\n")
second_chunk = await anext(iterator)
ready_payload = json.loads(second_chunk.removeprefix("data: ").strip())
self.assertEqual(ready_payload["type"], "events.ready")
self.assertEqual(ready_payload["chat_id"], "test-chat")
await runtime.event_bus.publish(
{"type": "cards.changed", "chat_id": "test-chat"},
chat_id="test-chat",
)
third_chunk = await anext(iterator)
event_payload = json.loads(third_chunk.removeprefix("data: ").strip())
self.assertEqual(event_payload["type"], "cards.changed")
self.assertEqual(event_payload["chat_id"], "test-chat")
await iterator.aclose()

118
tests/test_inbox_service.py Normal file
View file

@ -0,0 +1,118 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from typing import Any
from inbox_service import InboxService
NANOBOT_REPO_DIR = Path("/home/kacper/nanobot")
class InboxServiceTests(unittest.TestCase):
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self.inbox_dir = Path(self._tmpdir.name) / "inbox"
self.service = InboxService(repo_dir=NANOBOT_REPO_DIR, inbox_dir=self.inbox_dir)
def tearDown(self) -> None:
self._tmpdir.cleanup()
def test_capture_list_and_dismiss_item(self) -> None:
captured = self.service.capture_item(
title="",
text="Pack chargers for Japan",
kind="task",
source="web-ui",
due="2026-04-10",
body="Remember the USB-C one too.",
session_id="web-123",
tags=["travel", "#Japan"],
confidence=0.75,
)
self.assertEqual(captured["title"], "Pack chargers for Japan")
self.assertEqual(captured["kind"], "task")
self.assertEqual(captured["source"], "web-ui")
self.assertEqual(captured["suggested_due"], "2026-04-10")
self.assertEqual(captured["metadata"]["source_session"], "web-123")
self.assertIn("## Raw Capture", captured["body"])
self.assertIn("#travel", captured["tags"])
self.assertIn("#japan", captured["tags"])
listed = self.service.list_items(
status_filter=None,
kind_filter=None,
include_closed=False,
limit=None,
tags=[],
)
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["path"], captured["path"])
dismissed = self.service.dismiss_item(captured["path"])
self.assertEqual(dismissed["status"], "dismissed")
self.assertEqual(self.service.open_items(), [])
class InboxServiceAcceptTaskTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self.inbox_dir = Path(self._tmpdir.name) / "inbox"
self.service = InboxService(repo_dir=NANOBOT_REPO_DIR, inbox_dir=self.inbox_dir)
self.item = self.service.capture_item(
title="Find a good PWA install guide",
text="Find a good PWA install guide",
kind="task",
source="web-ui",
due="",
body="Look for something practical, not generic.",
session_id="web-456",
tags=["research"],
confidence=0.9,
)
def tearDown(self) -> None:
self._tmpdir.cleanup()
async def test_accept_item_builds_grooming_prompt_with_hints(self) -> None:
captured: dict[str, Any] = {}
async def fake_run_nanobot_turn(message: str, **kwargs: Any) -> dict[str, Any]:
captured["message"] = message
captured["kwargs"] = kwargs
return {"status": "ok", "reply": "accepted"}
def fake_notification_to_event(payload: dict[str, Any]) -> dict[str, Any] | None:
return payload
result = await self.service.accept_item_as_task(
item=self.item["path"],
lane="committed",
title="Install the PWA correctly",
due="2026-04-12",
body_text="Prefer something with screenshots.",
tags=["#pwa", "#research"],
run_nanobot_turn=fake_run_nanobot_turn,
notification_to_event=fake_notification_to_event,
)
self.assertEqual(result["status"], "ok")
message = captured["message"]
self.assertIn("Use the inbox_board tool and the task_helper_card tool.", message)
self.assertIn(f"Inbox item path: {self.item['path']}", message)
self.assertIn("Title hint from UI: Install the PWA correctly", message)
self.assertIn("Description hint from UI: Prefer something with screenshots.", message)
self.assertIn("Due hint from UI: 2026-04-12", message)
self.assertIn("Tag hint from UI: #pwa, #research", message)
self.assertIn("Preferred destination lane: committed", message)
kwargs = captured["kwargs"]
self.assertEqual(kwargs["chat_id"], "inbox-groomer")
self.assertEqual(kwargs["timeout_seconds"], 90.0)
self.assertEqual(kwargs["metadata"]["source"], "web-inbox-card")
self.assertEqual(kwargs["metadata"]["ui_action"], "accept_task")
self.assertEqual(kwargs["metadata"]["inbox_item"], self.item["path"])
self.assertIs(kwargs["notification_to_event"], fake_notification_to_event)

View file

@ -0,0 +1,131 @@
from __future__ import annotations
import asyncio
import unittest
from typing import Any
from tool_job_service import ToolJobService
async def wait_for_job_status(
service: ToolJobService,
job_id: str,
*,
status: str,
attempts: int = 50,
) -> dict[str, Any]:
for _ in range(attempts):
payload = await service.get_job(job_id)
if payload is not None and payload["status"] == status:
return payload
await asyncio.sleep(0.01)
raise AssertionError(f"job {job_id} never reached status {status}")
class ToolJobServiceTests(unittest.IsolatedAsyncioTestCase):
async def test_start_job_completes_and_returns_unwrapped_result(self) -> None:
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
self.assertEqual(method, "tool.call")
self.assertEqual(params["name"], "demo.tool")
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True, "arguments": params["arguments"]},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("demo.tool", {"count": 3})
self.assertEqual(job["status"], "queued")
self.assertEqual(job["tool_name"], "demo.tool")
completed = await wait_for_job_status(service, job["job_id"], status="completed")
self.assertEqual(
completed["result"],
{
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True, "arguments": {"count": 3}},
"is_json": True,
},
)
self.assertIsNone(completed["error"])
async def test_shutdown_cancels_running_jobs(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
await gate.wait()
return {"parsed": {"done": True}}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("slow.tool", {})
await asyncio.sleep(0.01)
await service.shutdown()
failed = await wait_for_job_status(service, job["job_id"], status="failed")
self.assertEqual(failed["error"], "tool job cancelled")
self.assertIsNone(failed["result"])
async def test_failed_job_exposes_error_code_when_present(self) -> None:
class ToolFailure(RuntimeError):
def __init__(self, message: str, code: int) -> None:
super().__init__(message)
self.code = code
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
raise ToolFailure("boom", 418)
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("broken.tool", {})
failed = await wait_for_job_status(service, job["job_id"], status="failed")
self.assertEqual(failed["error"], "boom")
self.assertEqual(failed["error_code"], 418)
async def test_subscribers_receive_running_and_completed_updates(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
gate.set()
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("demo.tool", {})
subscription = await service.subscribe_job(job["job_id"])
self.assertIsNotNone(subscription)
subscription_id, queue = subscription or ("", asyncio.Queue())
await gate.wait()
first_update = await asyncio.wait_for(queue.get(), timeout=0.2)
self.assertEqual(first_update["status"], "running")
second_update = await asyncio.wait_for(queue.get(), timeout=0.2)
self.assertEqual(second_update["status"], "completed")
self.assertEqual(second_update["result"]["tool_name"], "demo.tool")
await service.unsubscribe_job(job["job_id"], subscription_id)

View file

@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
import json
import unittest
from types import SimpleNamespace
from routes.tools import stream_tool_job
from tool_job_service import ToolJobService
class DummyRequest:
async def is_disconnected(self) -> bool:
return False
class ToolStreamRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_stream_tool_job_emits_current_and_terminal_updates(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, object]) -> dict[str, object]:
gate.set()
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
runtime = SimpleNamespace(tool_job_service=service)
job = await service.start_job("demo.tool", {})
await gate.wait()
response = await stream_tool_job(job["job_id"], DummyRequest(), runtime=runtime)
iterator = response.body_iterator
first_chunk = await anext(iterator)
self.assertEqual(first_chunk, ": stream-open\n\n")
second_chunk = await anext(iterator)
first_payload = json.loads(second_chunk.removeprefix("data: ").strip())
self.assertEqual(first_payload["type"], "tool.job")
self.assertEqual(first_payload["job"]["job_id"], job["job_id"])
if first_payload["job"]["status"] == "completed":
terminal_payload = first_payload
else:
third_chunk = await anext(iterator)
terminal_payload = json.loads(third_chunk.removeprefix("data: ").strip())
self.assertEqual(terminal_payload["job"]["status"], "completed")
self.assertEqual(terminal_payload["job"]["result"]["tool_name"], "demo.tool")
await iterator.aclose()
await service.shutdown()

View file

@ -0,0 +1,42 @@
from __future__ import annotations
import asyncio
import unittest
from ui_event_bus import UiEventBus
class UiEventBusTests(unittest.IsolatedAsyncioTestCase):
async def test_publish_targets_chat_or_broadcasts(self) -> None:
bus = UiEventBus()
first_subscription, first_queue = await bus.subscribe("chat-a")
second_subscription, second_queue = await bus.subscribe("chat-b")
self.addAsyncCleanup(bus.unsubscribe, first_subscription)
self.addAsyncCleanup(bus.unsubscribe, second_subscription)
await bus.publish({"type": "cards.changed", "chat_id": "chat-a"}, chat_id="chat-a")
self.assertEqual((await asyncio.wait_for(first_queue.get(), timeout=0.1))["type"], "cards.changed")
self.assertTrue(second_queue.empty())
await bus.publish({"type": "sessions.changed"}, broadcast=True)
self.assertEqual(
(await asyncio.wait_for(first_queue.get(), timeout=0.1))["type"],
"sessions.changed",
)
self.assertEqual(
(await asyncio.wait_for(second_queue.get(), timeout=0.1))["type"],
"sessions.changed",
)
async def test_publish_drops_oldest_entries_when_queue_is_full(self) -> None:
bus = UiEventBus()
subscription_id, queue = await bus.subscribe("chat-a")
self.addAsyncCleanup(bus.unsubscribe, subscription_id)
for seq in range(70):
await bus.publish({"seq": seq}, chat_id="chat-a")
self.assertEqual(queue.qsize(), 64)
self.assertEqual((await queue.get())["seq"], 6)

Some files were not shown because too many files have changed in this diff Show more