feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
43
ARCHITECTURE_TODO.md
Normal file
43
ARCHITECTURE_TODO.md
Normal 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
126
CARD_RUNTIME.md
Normal 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
50
THEMING.md
Normal 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>
|
||||
```
|
||||
9
app_dependencies.py
Normal file
9
app_dependencies.py
Normal 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
488
card_store.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
17
examples/cards/instances/live-outdoor-aqi/card.json
Normal file
17
examples/cards/instances/live-outdoor-aqi/card.json
Normal 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"
|
||||
}
|
||||
17
examples/cards/instances/live-outdoor-aqi/state.json
Normal file
17
examples/cards/instances/live-outdoor-aqi/state.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
263
examples/cards/templates/calendar-agenda-live/card.js
Normal file
263
examples/cards/templates/calendar-agenda-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
584
examples/cards/templates/calendar-timeline-live/card.js
Normal file
584
examples/cards/templates/calendar-timeline-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
778
examples/cards/templates/calendar-timeline-weather-live/card.js
Normal file
778
examples/cards/templates/calendar-timeline-weather-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
719
examples/cards/templates/git-diff-live/card.js
Normal file
719
examples/cards/templates/git-diff-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
258
examples/cards/templates/list-total-live/card.js
Normal file
258
examples/cards/templates/list-total-live/card.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
268
examples/cards/templates/litellm-ups-usage-live/card.js
Normal file
268
examples/cards/templates/litellm-ups-usage-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
218
examples/cards/templates/live-bedroom-co2/card.js
Normal file
218
examples/cards/templates/live-bedroom-co2/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
266
examples/cards/templates/live-calendar-today/card.js
Normal file
266
examples/cards/templates/live-calendar-today/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
243
examples/cards/templates/live-weather-01545/card.js
Normal file
243
examples/cards/templates/live-weather-01545/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
309
examples/cards/templates/sensor-live/card.js
Normal file
309
examples/cards/templates/sensor-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
494
examples/cards/templates/today-briefing-live/card.js
Normal file
494
examples/cards/templates/today-briefing-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
21
examples/cards/templates/today-briefing-live/manifest.json
Normal file
21
examples/cards/templates/today-briefing-live/manifest.json
Normal 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"
|
||||
}
|
||||
32
examples/cards/templates/today-briefing-live/template.html
Normal file
32
examples/cards/templates/today-briefing-live/template.html
Normal 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>
|
||||
708
examples/cards/templates/todo-item-live/card.js
Normal file
708
examples/cards/templates/todo-item-live/card.js
Normal 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
Binary file not shown.
410
examples/cards/templates/upcoming-conditions-live/card.js
Normal file
410
examples/cards/templates/upcoming-conditions-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
356
examples/cards/templates/weather-live/card.js
Normal file
356
examples/cards/templates/weather-live/card.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
905
frontend/src/appShell/presentation.tsx
Normal file
905
frontend/src/appShell/presentation.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
363
frontend/src/cardRuntime/api.ts
Normal file
363
frontend/src/cardRuntime/api.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
81
frontend/src/cardRuntime/runtimeAssets.ts
Normal file
81
frontend/src/cardRuntime/runtimeAssets.ts
Normal 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
98
frontend/src/cardRuntime/runtimeHost.ts
Normal file
98
frontend/src/cardRuntime/runtimeHost.ts
Normal 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;
|
||||
}
|
||||
58
frontend/src/cardRuntime/runtimeTypes.ts
Normal file
58
frontend/src/cardRuntime/runtimeTypes.ts
Normal 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;
|
||||
}
|
||||
35
frontend/src/cardRuntime/runtimeUtils.ts
Normal file
35
frontend/src/cardRuntime/runtimeUtils.ts
Normal 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);
|
||||
}
|
||||
192
frontend/src/cardRuntime/store.ts
Normal file
192
frontend/src/cardRuntime/store.ts
Normal 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"));
|
||||
}
|
||||
124
frontend/src/components/CardBodyRenderer.tsx
Normal file
124
frontend/src/components/CardBodyRenderer.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
514
frontend/src/components/SessionDrawer.tsx
Normal file
514
frontend/src/components/SessionDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/WorkbenchOverlay.tsx
Normal file
194
frontend/src/components/WorkbenchOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
frontend/src/components/cardFeed/inbox.tsx
Normal file
399
frontend/src/components/cardFeed/inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/cardFeed/snooze.ts
Normal file
19
frontend/src/components/cardFeed/snooze.ts
Normal 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"));
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
140
frontend/src/hooks/webrtc/backend.ts
Normal file
140
frontend/src/hooks/webrtc/backend.ts
Normal 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;
|
||||
}
|
||||
543
frontend/src/hooks/webrtc/cards.ts
Normal file
543
frontend/src/hooks/webrtc/cards.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
399
frontend/src/hooks/webrtc/messages.ts
Normal file
399
frontend/src/hooks/webrtc/messages.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
319
frontend/src/hooks/webrtc/rtcTransport.ts
Normal file
319
frontend/src/hooks/webrtc/rtcTransport.ts
Normal 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 };
|
||||
}
|
||||
87
frontend/src/hooks/webrtc/types.ts
Normal file
87
frontend/src/hooks/webrtc/types.ts
Normal 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
49
frontend/src/lib/sse.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
252
frontend/src/theme/themes.ts
Normal file
252
frontend/src/theme/themes.ts
Normal 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;
|
||||
}
|
||||
30
frontend/src/theme/useThemePreference.ts
Normal file
30
frontend/src/theme/useThemePreference.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
|
|
@ -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
157
inbox_service.py
Normal 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
210
message_pipeline.py
Normal 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
265
nanobot_api_client.py
Normal 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
15
route_helpers.py
Normal 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
19
routes/__init__.py
Normal 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
108
routes/cards.py
Normal 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
169
routes/inbox.py
Normal 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
114
routes/messages.py
Normal 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
29
routes/rtc.py
Normal 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
132
routes/sessions.py
Normal 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
92
routes/templates.py
Normal 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
138
routes/tools.py
Normal 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
103
routes/workbench.py
Normal 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
109
rtc_manager.py
Normal 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)
|
||||
24
scripts/card_runtime_fixture_card.mjs
Normal file
24
scripts/card_runtime_fixture_card.mjs
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
119
scripts/check_card_runtime.py
Normal file
119
scripts/check_card_runtime.py
Normal 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())
|
||||
57
scripts/check_card_runtime_fixture.mjs
Normal file
57
scripts/check_card_runtime_fixture.mjs
Normal 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();
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
139
scripts/sync_card_templates.py
Normal file
139
scripts/sync_card_templates.py
Normal 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
317
session_store.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
44
tests/test_events_route.py
Normal file
44
tests/test_events_route.py
Normal 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
118
tests/test_inbox_service.py
Normal 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)
|
||||
131
tests/test_tool_job_service.py
Normal file
131
tests/test_tool_job_service.py
Normal 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)
|
||||
61
tests/test_tool_stream_route.py
Normal file
61
tests/test_tool_stream_route.py
Normal 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()
|
||||
42
tests/test_ui_event_bus.py
Normal file
42
tests/test_ui_event_bus.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue