This commit is contained in:
kacper 2026-03-12 09:25:15 -04:00
parent b7614eb3f8
commit db4ce8b14f
22 changed files with 3557 additions and 823 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Local secrets and certs
.certs/
.env
.env.*
!.env.example
# Python runtime
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
# Local app data
cards.db
.todos/
# Frontend build and dependencies
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Editor / OS noise
.DS_Store

40
AGENTS.md Normal file
View file

@ -0,0 +1,40 @@
# Agent Instructions
## Project layout
- `app.py` — FastAPI server (writable)
- `frontend/` — Preact + TypeScript UI built with Vite
- `voice_rtc.py`, `supertonic_gateway.py`, `wisper.py` — read-only; do not modify
- `start.sh` — read-only startup script
- `.env.voice` — local env overrides (writable); sourced before `start.sh` defaults
## Toolchain
All frontend commands run from `frontend/` using `~/.bun/bin/bun`.
| Task | Command |
|---|---|
| Lint + format check | `~/.bun/bin/bun run check` |
| Build | `~/.bun/bin/bun run build` |
| Dead code | `~/.bun/bin/bunx knip` |
| Format a file | `~/.bun/bin/bun run biome format --write <file>` |
Always run `check` and `build` after making frontend changes. Both must pass with no errors before finishing.
## Linting rules
- Linter: Biome (config at `frontend/biome.json`)
- No `biome-ignore` suppressions — fix the code or disable the rule in `biome.json`
- All font sizes in CSS must use `rem`, not `px`
## Dead code
Run `~/.bun/bin/bunx knip` from `frontend/` to find unused files, exports, and types. The only expected false positive is `dist/assets/` build output.
## Backend
- Card instances are file-backed in `NANOBOT_WORKSPACE/cards/instances/<card-id>/card.json`
- Card HTML snapshots are stored beside metadata in `NANOBOT_WORKSPACE/cards/instances/<card-id>/render.html`
- Templates live in `NANOBOT_WORKSPACE/cards/templates/<template-key>/template.html` with `manifest.json`
- `GET /cards` returns all non-archived cards ordered by lane, state, priority, then update time
- `DELETE /cards/{id}` removes a card on dismiss

774
app.py
View file

@ -1,12 +1,20 @@
import asyncio import asyncio
import contextlib import contextlib
import html
import json import json
import os
import re
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable from typing import Any
from urllib.parse import urlparse, urlunparse
import httpx
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from supertonic_gateway import SuperTonicGateway from supertonic_gateway import SuperTonicGateway
@ -15,6 +23,29 @@ from voice_rtc import WebRTCVoiceSession
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist" DIST_DIR = BASE_DIR / "frontend" / "dist"
NANOBOT_CONFIG_PATH = Path(
os.getenv("NANOBOT_CONFIG_PATH", str(Path.home() / ".nanobot" / "config.json"))
).expanduser()
NANOBOT_WORKSPACE = Path(
os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))
).expanduser()
NANOBOT_SCRIPT_WORKSPACE = Path(
os.getenv("NANOBOT_SCRIPT_WORKSPACE", str(NANOBOT_WORKSPACE / "workspace"))
).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}
_MAX_SCRIPT_PROXY_ARGS = 16
_MAX_SCRIPT_PROXY_STDERR_CHARS = 2000
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Nanobot SuperTonic Wisper Web") app = FastAPI(title="Nanobot SuperTonic Wisper Web")
@ -27,25 +58,719 @@ app.add_middleware(
gateway = SuperTonicGateway() gateway = SuperTonicGateway()
# Session store: one voice session per connection (keyed by a simple counter).
# For this single-user app we keep at most one active session at a time.
_active_session: WebRTCVoiceSession | None = None _active_session: WebRTCVoiceSession | None = None
_active_queue: asyncio.Queue | None = None _active_queue: asyncio.Queue | None = None
_sender_task: asyncio.Task | None = None _sender_task: asyncio.Task | None = None
# ---------------------------------------------------------------------------
# Cards (file-backed)
# ---------------------------------------------------------------------------
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 _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 _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", "")) if kind == "question" else "",
"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"),
"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 _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 ""
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() -> list[dict[str, Any]]:
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
cards: list[dict[str, Any]] = []
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 card.get("state") == "archived":
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 _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 _list_templates(limit: int | None = None) -> list[dict[str, Any]]:
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
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:
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
def _to_typed_message(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
card = _coerce_card_record(payload)
if card is None:
return None
card["type"] = "card"
return card
return None
# ---------------------------------------------------------------------------
# Nanobot config / HA proxy
# ---------------------------------------------------------------------------
def _load_nanobot_config() -> dict[str, Any]:
try:
return json.loads(NANOBOT_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
def _get_home_assistant_mcp_config() -> tuple[str, dict[str, str]]:
cfg = _load_nanobot_config()
tools = cfg.get("tools") if isinstance(cfg, dict) else {}
if not isinstance(tools, dict):
raise RuntimeError("nanobot config missing tools section")
mcp_servers = tools.get("mcpServers")
if not isinstance(mcp_servers, dict):
raise RuntimeError("nanobot config missing tools.mcpServers section")
raw_server = mcp_servers.get("home assistant") or mcp_servers.get("home_assistant")
if not isinstance(raw_server, dict):
raise RuntimeError("home assistant MCP server is not configured")
url = str(raw_server.get("url", "")).strip()
if not url:
raise RuntimeError("home assistant MCP server URL is empty")
raw_headers = raw_server.get("headers", {})
headers: dict[str, str] = {}
if isinstance(raw_headers, dict):
for k, v in raw_headers.items():
headers[str(k)] = str(v)
return url, headers
def _home_assistant_origin(mcp_url: str) -> str:
parsed = urlparse(mcp_url.strip())
return urlunparse(parsed._replace(path="", params="", query="", fragment="")).rstrip("/")
def _normalize_home_assistant_proxy_path(target_path: str) -> str:
normalized = "/" + target_path.lstrip("/")
if normalized == "/":
raise ValueError("target path is required")
if normalized == "/api" or normalized.startswith("/api/"):
return normalized
return f"/api{normalized}"
def _resolve_workspace_script(script_path: str) -> Path:
normalized = script_path.strip().lstrip("/")
if not normalized:
raise ValueError("script path is required")
root = NANOBOT_SCRIPT_WORKSPACE.resolve()
candidate = (root / normalized).resolve()
try:
candidate.relative_to(root)
except ValueError as exc:
raise ValueError("script path escapes workspace") from exc
if not candidate.is_file():
raise ValueError(f"script not found: {normalized}")
if candidate.suffix.lower() != ".py":
raise ValueError("only Python scripts are supported")
return candidate
def _script_proxy_args(request: Request) -> list[str]:
unknown_keys = sorted({key for key in request.query_params.keys() if key != "arg"})
if unknown_keys:
raise ValueError(
"unsupported script query parameters: " + ", ".join(unknown_keys)
)
args = [str(value) for value in request.query_params.getlist("arg")]
if len(args) > _MAX_SCRIPT_PROXY_ARGS:
raise ValueError(
f"too many script arguments ({len(args)} > {_MAX_SCRIPT_PROXY_ARGS})"
)
return args
# ---------------------------------------------------------------------------
# API routes
# ---------------------------------------------------------------------------
@app.get("/health") @app.get("/health")
async def health() -> JSONResponse: async def health() -> JSONResponse:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@app.api_route(
"/ha/proxy/{target_path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
)
async def home_assistant_proxy(target_path: str, request: Request) -> Response:
raw_target = target_path.strip()
if not raw_target:
return JSONResponse({"error": "target path is required"}, status_code=400)
try:
mcp_url, auth_headers = _get_home_assistant_mcp_config()
origin = _home_assistant_origin(mcp_url)
api_path = _normalize_home_assistant_proxy_path(raw_target)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=502)
target_url = f"{origin}{api_path}"
if request.url.query:
target_url = f"{target_url}?{request.url.query}"
outbound_headers = dict(auth_headers)
incoming_content_type = request.headers.get("content-type")
if incoming_content_type:
outbound_headers["Content-Type"] = incoming_content_type
incoming_accept = request.headers.get("accept")
if incoming_accept:
outbound_headers["Accept"] = incoming_accept
outbound_body = None if request.method in {"GET", "HEAD"} else await request.body()
try:
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
upstream = await client.request(
request.method,
target_url,
headers=outbound_headers,
content=outbound_body,
)
except httpx.RequestError as exc:
return JSONResponse(
{"error": f"Home Assistant connection failed: {exc}"},
status_code=502,
)
media_type = upstream.headers.get("content-type") or "application/json"
return Response(
content=upstream.content,
status_code=upstream.status_code,
media_type=media_type,
)
@app.get("/script/proxy/{script_path:path}")
async def workspace_script_proxy(script_path: str, request: Request) -> JSONResponse:
try:
script_file = _resolve_workspace_script(script_path)
args = _script_proxy_args(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
try:
process = await asyncio.create_subprocess_exec(
sys.executable,
str(script_file),
*args,
cwd=str(script_file.parent),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
return JSONResponse({"error": "script execution timed out"}, status_code=504)
except OSError as exc:
return JSONResponse({"error": f"failed to start script: {exc}"}, status_code=502)
stderr_text = stderr.decode("utf-8", errors="replace").strip()
if process.returncode != 0:
return JSONResponse(
{
"error": f"script exited with code {process.returncode}",
"stderr": stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS],
},
status_code=502,
)
stdout_text = stdout.decode("utf-8", errors="replace").strip()
try:
payload = json.loads(stdout_text)
except json.JSONDecodeError as exc:
return JSONResponse(
{
"error": f"script did not return valid JSON: {exc}",
"stderr": stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS],
},
status_code=502,
)
return JSONResponse(payload)
@app.get("/cards")
async def get_cards() -> JSONResponse:
return JSONResponse(_load_cards())
@app.delete("/cards/{card_id}")
async def delete_card(card_id: str) -> JSONResponse:
if not _normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
_delete_card(card_id)
return JSONResponse({"status": "ok"})
@app.get("/templates")
async def get_templates() -> JSONResponse:
return JSONResponse(_list_templates())
@app.post("/templates")
async def save_template(request: Request) -> JSONResponse:
payload = await request.json()
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)
template_dir = _template_dir(key)
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",
}
)
@app.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})
@app.post("/message")
async def post_message(request: Request) -> JSONResponse:
payload = await request.json()
text = str(payload.get("text", "")).strip()
metadata = payload.get("metadata", {})
if not text:
return JSONResponse({"error": "empty message"}, status_code=400)
if not isinstance(metadata, dict):
metadata = {}
await gateway.send_user_message(text, metadata=metadata)
return JSONResponse({"status": "ok"})
@app.post("/rtc/offer") @app.post("/rtc/offer")
async def rtc_offer(request: Request) -> JSONResponse: async def rtc_offer(request: Request) -> JSONResponse:
global _active_session, _active_queue, _sender_task global _active_session, _active_queue, _sender_task
payload = await request.json() payload = await request.json()
# Tear down any previous session cleanly.
if _active_session is not None: if _active_session is not None:
await _active_session.close() await _active_session.close()
_active_session = None _active_session = None
@ -76,9 +801,7 @@ async def rtc_offer(request: Request) -> JSONResponse:
status_code=503, status_code=503,
) )
# Connect to nanobot if not already connected. await gateway.connect_nanobot()
await gateway.spawn_tui()
return JSONResponse(answer) return JSONResponse(answer)
@ -105,20 +828,45 @@ async def _sender_loop(
if event.role == "nanobot-tts": if event.role == "nanobot-tts":
await voice_session.queue_output_text(event.text) await voice_session.queue_output_text(event.text)
continue continue
voice_session.send_to_datachannel(event.to_dict()) typed_event = _to_typed_message(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"
voice_session.send_to_datachannel(payload)
continue
voice_session.send_to_datachannel(typed_event)
@app.on_event("startup")
async def on_startup() -> None:
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
_sync_templates_context_file()
# Serve the Vite-built frontend as static files.
# This must come AFTER all API routes so the API endpoints are not shadowed.
if DIST_DIR.exists(): if DIST_DIR.exists():
# Mount assets sub-directory (hashed JS/CSS)
assets_dir = DIST_DIR / "assets" assets_dir = DIST_DIR / "assets"
if assets_dir.exists(): if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
if CARD_TEMPLATES_DIR.exists():
app.mount(
"/card-templates",
StaticFiles(directory=str(CARD_TEMPLATES_DIR)),
name="card-templates",
)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse: async def spa_fallback(full_path: str) -> FileResponse:
candidate = DIST_DIR / full_path candidate = DIST_DIR / full_path
if candidate.is_file(): if candidate.is_file():
return FileResponse(str(candidate)) return FileResponse(str(candidate))
return FileResponse(str(DIST_DIR / "index.html")) response = FileResponse(str(DIST_DIR / "index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response

View file

@ -18,6 +18,9 @@
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": {
"useGenericFontNames": "off"
},
"correctness": { "correctness": {
"noUnusedVariables": "error", "noUnusedVariables": "error",
"noUnusedImports": "error", "noUnusedImports": "error",

View file

@ -13,6 +13,7 @@
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.6",
"@preact/preset-vite": "^2.8.1", "@preact/preset-vite": "^2.8.1",
"@types/three": "^0.165.0", "@types/three": "^0.165.0",
"knip": "^5.86.0",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.11",
}, },
@ -79,6 +80,12 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@ -135,6 +142,54 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="],
"@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="],
"@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="],
"@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="],
"@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="],
"@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="],
"@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="],
"@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="],
"@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="],
"@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="],
"@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="],
"@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="],
"@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="],
"@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="],
"@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="],
"@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="],
"@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="],
"@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="],
"@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="],
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="],
"@preact/preset-vite": ["@preact/preset-vite@2.10.3", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" } }, "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg=="], "@preact/preset-vite": ["@preact/preset-vite@2.10.3", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" } }, "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg=="],
"@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="], "@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="],
@ -199,8 +254,12 @@
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.165.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.1", "@types/stats.js": "*", "@types/webxr": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw=="], "@types/three": ["@types/three@0.165.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.1", "@types/stats.js": "*", "@types/webxr": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw=="],
@ -213,6 +272,8 @@
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="],
@ -243,20 +304,42 @@
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"knip": ["knip@5.86.0", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-tGpRCbP+L+VysXnAp1bHTLQ0k/SdC3M3oX18+Cpiqax1qdS25iuCPzpK8LVmAKARZv0Ijri81Wq09Rzk0JTl+Q=="],
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@ -265,8 +348,14 @@
"marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], "marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -277,6 +366,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@ -285,32 +376,58 @@
"preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="], "preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stack-trace": ["stack-trace@1.0.0-pre2", "", {}, "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A=="], "stack-trace": ["stack-trace@1.0.0-pre2", "", {}, "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], "three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-prerender-plugin": ["vite-prerender-plugin@0.5.12", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x" } }, "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g=="], "vite-prerender-plugin": ["vite-prerender-plugin@0.5.12", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x" } }, "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g=="],
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
} }
} }

View file

@ -20,6 +20,7 @@
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.6",
"@preact/preset-vite": "^2.8.1", "@preact/preset-vite": "^2.8.1",
"@types/three": "^0.165.0", "@types/three": "^0.165.0",
"knip": "^5.86.0",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11" "vite": "^5.2.11"
} }

View file

@ -1,61 +1,275 @@
import { useCallback, useEffect } from "preact/hooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { AgentIndicator } from "./components/AgentIndicator"; import { AgentIndicator } from "./components/AgentIndicator";
import { CardFeed } from "./components/CardFeed";
import { ControlBar, VoiceStatus } from "./components/Controls"; import { ControlBar, VoiceStatus } from "./components/Controls";
import { LogPanel } from "./components/LogPanel"; import { LogPanel } from "./components/LogPanel";
import { ToastContainer } from "./components/Toast";
import { useAudioMeter } from "./hooks/useAudioMeter"; import { useAudioMeter } from "./hooks/useAudioMeter";
import { usePTT } from "./hooks/usePTT"; import { usePTT } from "./hooks/usePTT";
import { useWebRTC } from "./hooks/useWebRTC"; import { useWebRTC } from "./hooks/useWebRTC";
import type { CardItem, CardMessageMetadata, JsonValue } from "./types";
export function App() { const SWIPE_THRESHOLD_PX = 64;
const rtc = useWebRTC(); const SWIPE_DIRECTION_RATIO = 1.15;
const audioLevel = useAudioMeter(rtc.remoteStream);
const { agentStateOverride, handlePointerDown, handlePointerUp } = usePTT({ interface AppRtcActions {
connected: rtc.connected, connect(): Promise<void>;
onSendPtt: (pressed) => rtc.sendJson({ type: "voice-ptt", pressed }), sendJson(
onBootstrap: rtc.connect, 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;
}
const effectiveAgentState = agentStateOverride ?? rtc.agentState; function buildCardMetadata(card: CardItem): CardMessageMetadata {
const metadata: CardMessageMetadata = {
useEffect(() => { card_id: card.serverId,
document.addEventListener("pointerdown", handlePointerDown, { passive: false }); card_slot: card.slot,
document.addEventListener("pointerup", handlePointerUp, { passive: false }); card_title: card.title,
document.addEventListener("pointercancel", handlePointerUp, { passive: false }); card_lane: card.lane,
return () => { card_template_key: card.templateKey,
document.removeEventListener("pointerdown", handlePointerDown); card_context_summary: card.contextSummary,
document.removeEventListener("pointerup", handlePointerUp); card_response_value: card.responseValue,
document.removeEventListener("pointercancel", handlePointerUp);
}; };
}, [handlePointerDown, handlePointerUp]); const liveContent = card.serverId
? window.__nanobotGetCardLiveContent?.(card.serverId)
: undefined;
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
return metadata;
}
function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) {
return (
<div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">Using card</div>
<div class="agent-card-context-row">
<div class="agent-card-context-title">{card.title}</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: "agent" | "feed",
setView: (view: "agent" | "feed") => 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: "agent" | "feed") => 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 useControlActions(rtc: AppRtcActions) {
const handleReset = useCallback(async () => { const handleReset = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
await rtc.connect(); await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" }); rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]); }, [rtc]);
const handleChoice = useCallback( const handleToggleTextOnly = useCallback(
(requestId: string, value: string) => { async (enabled: boolean) => {
rtc.sendJson({ type: "ui-response", request_id: requestId, value }); rtc.setTextOnly(enabled);
if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
}, },
[rtc], [rtc],
); );
return { handleReset, handleToggleTextOnly };
}
export function App() {
const rtc = useWebRTC();
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<"agent" | "feed">("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const autoOpenedFeedRef = useRef(false);
const selectedCard = useMemo(
() =>
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[rtc.cards, selectedCardId],
);
const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard],
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly,
onSendPtt: (pressed) =>
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
onBootstrap: rtc.connect,
});
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,
);
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]);
useEffect(() => {
if (autoOpenedFeedRef.current || rtc.cards.length === 0) return;
autoOpenedFeedRef.current = true;
setView("feed");
}, [rtc.cards.length]);
useEffect(() => {
if (!selectedCardId) return;
if (rtc.cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [rtc.cards, selectedCardId]);
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, 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;
setSelectedCardId(null);
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]);
return ( return (
<> <>
<ControlBar onReset={handleReset} /> <div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
<LogPanel lines={rtc.logLines} /> <div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
<section class="workspace-panel workspace-agent">
{view === "agent" && (
<ControlBar
onReset={handleResetWithSelection}
textOnly={rtc.textOnly}
onToggleTextOnly={handleToggleTextOnly}
/>
)}
{view === "agent" && selectedCard && (
<AgentCardContext card={selectedCard} onClear={() => setSelectedCardId(null)} />
)}
{view === "agent" && (
<LogPanel
lines={rtc.logLines}
disabled={!rtc.connected}
onSendMessage={handleSendMessage}
onExpandChange={setComposing}
/>
)}
<AgentIndicator <AgentIndicator
state={effectiveAgentState} state={effectiveAgentState}
connected={rtc.connected} connected={rtc.connected}
connecting={rtc.connecting} connecting={rtc.connecting}
audioLevel={audioLevel} audioLevel={audioLevel}
viewActive
onPointerDown={() => {}} onPointerDown={() => {}}
onPointerUp={() => {}} onPointerUp={() => {}}
/> />
</section>
<section class="workspace-panel workspace-feed">
<CardFeed
cards={rtc.cards}
viewActive
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</section>
</div>
</div>
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} /> <VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
<ToastContainer toasts={rtc.toasts} onDismiss={rtc.dismissToast} onChoice={handleChoice} />
</> </>
); );
} }

View file

@ -4,7 +4,7 @@ const AudioContextCtor =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined; : undefined;
export interface AudioMeter { interface AudioMeter {
connect(stream: MediaStream): void; connect(stream: MediaStream): void;
getLevel(): number; getLevel(): number;
destroy(): void; destroy(): void;

View file

@ -7,6 +7,7 @@ interface Props {
connected: boolean; connected: boolean;
connecting: boolean; connecting: boolean;
audioLevel: number; audioLevel: number;
viewActive: boolean;
onPointerDown(): void; onPointerDown(): void;
onPointerUp(): void; onPointerUp(): void;
} }
@ -16,6 +17,7 @@ export function AgentIndicator({
connected, connected,
connecting, connecting,
audioLevel, audioLevel,
viewActive,
onPointerDown, onPointerDown,
onPointerUp, onPointerUp,
}: Props) { }: Props) {
@ -49,7 +51,11 @@ export function AgentIndicator({
}, [audioLevel]); }, [audioLevel]);
return ( return (
<div id="agentIndicator" class={`agentIndicator visible ${state}`} data-ptt="1"> <div
id="agentIndicator"
class={`agentIndicator${viewActive ? " visible" : ""} ${state}`}
data-ptt="1"
>
<div <div
id="agentViz" id="agentViz"
class="agentViz" class="agentViz"

View file

@ -0,0 +1,353 @@
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { CardItem, CardLane, JsonValue } from "../types";
const EXECUTABLE_SCRIPT_TYPES = new Set([
"",
"text/javascript",
"application/javascript",
"module",
]);
const cardLiveContentStore = new Map<string, JsonValue>();
function readCardState(script: HTMLScriptElement | null): Record<string, unknown> {
const root = script?.closest("[data-nanobot-card-root]");
if (!(root instanceof HTMLElement)) return {};
const stateEl = root.querySelector('script[data-card-state][type="application/json"]');
if (!(stateEl instanceof HTMLScriptElement)) return {};
try {
const parsed = JSON.parse(stateEl.textContent || "{}");
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLElement | null {
if (!(target instanceof HTMLElement)) return null;
if (target.matches("[data-nanobot-card-root]")) return target;
return target.closest("[data-nanobot-card-root]");
}
function setCardLiveContent(
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
if (snapshot === null || snapshot === undefined) {
cardLiveContentStore.delete(cardId);
return;
}
try {
cardLiveContentStore.set(cardId, JSON.parse(JSON.stringify(snapshot)) as JsonValue);
} catch {
cardLiveContentStore.delete(cardId);
}
}
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim();
if (!key) return undefined;
const value = cardLiveContentStore.get(key);
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}
function ensureCardStateHelper(): void {
if (!window.__nanobotGetCardState) {
window.__nanobotGetCardState = readCardState;
}
if (!window.__nanobotSetCardLiveContent) {
window.__nanobotSetCardLiveContent = setCardLiveContent;
}
if (!window.__nanobotGetCardLiveContent) {
window.__nanobotGetCardLiveContent = getCardLiveContent;
}
}
declare global {
interface Window {
__nanobotGetCardState?: (script: HTMLScriptElement | null) => Record<string, unknown>;
__nanobotSetCardLiveContent?: (
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
) => void;
__nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined;
}
}
const LANE_TITLES: Record<CardLane, string> = {
attention: "Attention",
work: "Work",
context: "Context",
history: "History",
};
const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"];
interface CardProps {
card: CardItem;
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="6" cy="12" r="1.75" fill="currentColor" />
<circle cx="12" cy="12" r="1.75" fill="currentColor" />
<circle cx="18" cy="12" r="1.75" fill="currentColor" />
</svg>
);
}
function CardTextBody({ card }: { card: CardItem }) {
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
ensureCardStateHelper();
const root = bodyRef.current;
if (!root) return;
const scripts = Array.from(root.querySelectorAll("script"));
for (const oldScript of scripts) {
const type = (oldScript.getAttribute("type") || "").trim().toLowerCase();
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) continue;
const runtimeScript = document.createElement("script");
for (const attr of oldScript.attributes) runtimeScript.setAttribute(attr.name, attr.value);
runtimeScript.text = oldScript.textContent || "";
oldScript.replaceWith(runtimeScript);
}
return () => {
window.__nanobotSetCardLiveContent?.(bodyRef.current, null);
};
}, [card.id, card.content]);
const looksLikeHtml = /^\s*<[a-zA-Z]/.test(card.content);
const html = looksLikeHtml ? card.content : (marked.parse(card.content) as string);
return <div ref={bodyRef} class="card-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
function CardQuestionBody({
card,
responding,
onChoice,
}: {
card: CardItem;
responding: boolean;
onChoice(cardId: string, value: string): void;
}) {
const canAnswer = card.state === "active" && !responding && !!card.serverId;
return (
<>
{card.content && <div class="card-body">{card.content}</div>}
<div class="card-question">{card.question}</div>
<div class="card-choices">
{(card.choices ?? []).map((label) => (
<button
key={label}
class="card-choice-btn"
type="button"
disabled={!canAnswer}
onClick={(e) => {
e.stopPropagation();
if (!card.serverId) return;
onChoice(card.serverId, label);
}}
>
{label}
</button>
))}
</div>
{card.responseValue && <div class="card-response">Selected: {card.responseValue}</div>}
</>
);
}
function CardHeader({
card,
onDismiss,
onAskCard,
}: {
card: CardItem;
onDismiss(): void;
onAskCard(card: CardItem): void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) return;
if (menuRef.current?.contains(event.target)) return;
setMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [menuOpen]);
return (
<header class={`card-header${card.kind === "text" ? " floating" : ""}`}>
{card.kind !== "text" && (
<div class="card-title-wrap">
<div class="card-title-line">
{card.title && <span class="card-title">{card.title}</span>}
</div>
<div class="card-meta">
{card.state !== "active" && (
<span class={`card-state state-${card.state}`}>{card.state}</span>
)}
</div>
</div>
)}
<div ref={menuRef} class="card-menu-wrap">
<button
class={`card-menu-trigger${menuOpen ? " open" : ""}`}
type="button"
aria-label="Card actions"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((current) => !current);
}}
>
<MoreIcon />
</button>
{menuOpen && (
<div class="card-menu" role="menu">
{card.kind === "text" && (
<button
class="card-menu-item"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
onAskCard(card);
}}
>
Ask Nanobot
</button>
)}
<button
class="card-menu-item danger"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
onDismiss();
}}
>
Dismiss
</button>
</div>
)}
</div>
</header>
);
}
function Card({ card, onDismiss, onChoice, onAskCard }: CardProps) {
const [dismissing, setDismissing] = useState(false);
const [responding, setResponding] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
if (card.state !== "active") setResponding(false);
}, [card.state]);
const dismiss = () => {
if (dismissing) return;
setDismissing(true);
timerRef.current = setTimeout(() => onDismiss(card.id), 220);
};
return (
<article class={`card kind-${card.kind}${dismissing ? " dismissing" : ""} state-${card.state}`}>
<CardHeader card={card} onDismiss={dismiss} onAskCard={onAskCard} />
{card.kind === "text" ? (
<CardTextBody card={card} />
) : (
<CardQuestionBody
card={card}
responding={responding}
onChoice={(cardId, value) => {
setResponding(true);
onChoice(cardId, value);
}}
/>
)}
{card.contextSummary && card.kind !== "text" && (
<footer class="card-footer">{card.contextSummary}</footer>
)}
</article>
);
}
interface CardFeedProps {
cards: CardItem[];
viewActive: boolean;
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}
export function CardFeed({ cards, viewActive, onDismiss, onChoice, onAskCard }: CardFeedProps) {
const groups = useMemo(
() =>
LANE_ORDER.map((lane) => ({
lane,
title: LANE_TITLES[lane],
cards: cards.filter((card) => card.lane === lane),
})).filter((group) => group.cards.length > 0),
[cards],
);
return (
<div id="card-feed" class={viewActive ? "feed-view" : ""}>
{groups.map((group) => (
<section key={group.lane} class="card-group">
<div class="card-group-title">{group.title}</div>
<div class="card-group-list">
{group.cards.map((card) => (
<Card
key={card.id}
card={card}
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
))}
</div>
</section>
))}
</div>
);
}

View file

@ -3,6 +3,63 @@ interface VoiceStatusProps {
visible: boolean; 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">
<path
d="M6 7.5h12a2.5 2.5 0 0 1 2.5 2.5v5A2.5 2.5 0 0 1 18 17.5H11l-4.5 3v-3H6A2.5 2.5 0 0 1 3.5 15v-5A2.5 2.5 0 0 1 6 7.5Z"
fill="currentColor"
/>
<path d="M8 11h8" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="1.5" />
<path d="M8 14h5" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="1.5" />
</svg>
);
}
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) { export function VoiceStatus({ text, visible }: VoiceStatusProps) {
return ( return (
<div id="voiceStatus" class={visible ? "visible" : ""}> <div id="voiceStatus" class={visible ? "visible" : ""}>
@ -13,22 +70,52 @@ export function VoiceStatus({ text, visible }: VoiceStatusProps) {
interface ControlBarProps { interface ControlBarProps {
onReset(): void; onReset(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): void;
} }
export function ControlBar({ onReset }: ControlBarProps) { export function ControlBar({ onReset, textOnly, onToggleTextOnly }: ControlBarProps) {
const toggleLabel = textOnly ? "Text-only mode on" : "Voice mode on";
return ( return (
<div id="controls"> <div id="controls">
<button <button
id="resetSessionBtn" id="textOnlyToggleBtn"
class="control-btn" class={`control-switch${textOnly ? " active" : ""}`}
type="button" 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) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onReset(); onReset();
}} }}
> >
Reset <ResetIcon />
</button> </button>
</div> </div>
); );

View file

@ -0,0 +1,45 @@
interface FABProps {
view: "agent" | "feed";
unreadCount: number;
pttActive: boolean;
}
function IconAgent() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="4" fill="currentColor" opacity="0.9" />
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" opacity="0.45" />
<circle cx="11" cy="11" r="10.25" stroke="currentColor" stroke-width="1.5" opacity="0.2" />
</svg>
);
}
function IconFeed() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<rect x="3" y="4" width="16" height="3.5" rx="1.75" fill="currentColor" opacity="0.9" />
<rect x="3" y="9.25" width="16" height="3.5" rx="1.75" fill="currentColor" opacity="0.6" />
<rect x="3" y="14.5" width="10" height="3.5" rx="1.75" fill="currentColor" opacity="0.35" />
</svg>
);
}
export function FAB({ view, unreadCount, pttActive }: FABProps) {
const label =
view === "agent" ? "Switch to feed (hold to talk)" : "Switch to agent (hold to talk)";
const badgeVisible = unreadCount > 0 && view === "agent";
return (
<button
id="fab"
type="button"
aria-label={label}
data-ptt="1"
data-fab="1"
class={pttActive ? "ptt-active" : ""}
>
{view === "agent" ? <IconFeed /> : <IconAgent />}
{badgeVisible && <span id="fab-badge">{unreadCount > 99 ? "99+" : unreadCount}</span>}
</button>
);
}

View file

@ -1,38 +1,199 @@
import { useEffect, useRef } from "preact/hooks"; import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { LogLine } from "../types"; import type { LogLine } from "../types";
interface Props { interface Props {
lines: LogLine[]; lines: LogLine[];
disabled: boolean;
onSendMessage(text: string): Promise<void>;
onExpandChange?(expanded: boolean): void;
} }
export function LogPanel({ lines }: Props) { interface LogViewProps {
const innerRef = useRef<HTMLDivElement>(null); lines: LogLine[];
scrollRef: { current: HTMLElement | null };
// Scroll to top (newest line — column-reverse layout) after each update }
useEffect(() => {
const el = innerRef.current?.parentElement;
if (el) el.scrollTop = 0;
}, [lines]);
function SendIcon() {
return ( return (
<div id="log"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<div id="log-inner" ref={innerRef}> <path d="M2 9L16 2L9.5 16L8 10.5L2 9Z" fill="currentColor" />
{lines.map((line) => { </svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function formatLine(line: LogLine): string {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : ""; const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase(); const role = line.role.trim().toLowerCase();
let text: string;
if (role === "nanobot") { if (role === "nanobot") {
text = `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`; return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
} else {
text = `[${time}] ${line.role}: ${line.text}`;
} }
return ( if (role === "tool") {
<div key={line.id} class={`line ${line.role}`}> return `[${time}] tool: ${line.text}`;
{text} }
</div> return `[${time}] ${line.role}: ${line.text}`;
}
function LogCompose({
disabled,
sending,
text,
setText,
onClose,
onSend,
}: {
disabled: boolean;
sending: boolean;
text: string;
setText(value: string): void;
onClose(): void;
onSend(): void;
}) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
},
[onClose, onSend],
); );
})}
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>
</div> </div>
</div> </div>
); );
} }
function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
return (
<div
id="log-scroll"
ref={(node) => {
scrollRef.current = node;
}}
>
<div id="log-inner">
{lines.map((line) => (
<div key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</div>
))}
</div>
</div>
);
}
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
return (
<button
id="log-collapsed"
ref={(node) => {
scrollRef.current = node;
}}
type="button"
aria-label="Open message composer"
onClick={onExpand}
>
<div id="log-inner">
{lines.map((line) => (
<span key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</span>
))}
</div>
</button>
);
}
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, expanded]);
const collapse = useCallback(() => {
setExpanded(false);
setText("");
}, []);
const expand = useCallback(() => {
if (!expanded) setExpanded(true);
}, [expanded]);
const send = useCallback(async () => {
const message = text.trim();
if (!message || sending || disabled) return;
setSending(true);
try {
await onSendMessage(message);
setText("");
} catch (err) {
window.alert(`Could not send message: ${String(err)}`);
} finally {
setSending(false);
}
}, [disabled, onSendMessage, sending, text]);
return (
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
{expanded ? (
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
) : (
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
)}
{expanded && (
<LogCompose
disabled={disabled}
sending={sending}
text={text}
setText={setText}
onClose={collapse}
onSend={() => {
void send();
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
import { useCallback, useRef, useState } from "preact/hooks";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
interface TextInputProps {
disabled: boolean;
onExpandChange?(expanded: boolean): void;
}
function ComposeIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M3 13.5V15.5H5L13.5 7L11.5 5L3 13.5ZM15.2 5.3C15.6 4.9 15.6 4.3 15.2 3.9L14.1 2.8C13.7 2.4 13.1 2.4 12.7 2.8L11.9 3.6L13.9 5.6L15.2 5.3Z"
fill="currentColor"
/>
</svg>
);
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 9L16 2L9.5 16L8 10.5L2 9Z" fill="currentColor" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
interface ExpandedBarProps {
text: string;
disabled: boolean;
sending: boolean;
inputRef: { current: HTMLTextAreaElement | null };
onInput(val: string): void;
onKeyDown(e: KeyboardEvent): void;
onBlur(): void;
onSend(): void;
onClose(): void;
stopProp(e: Event): void;
}
function ExpandedBar({
text,
disabled,
sending,
inputRef,
onInput,
onKeyDown,
onBlur,
onSend,
onClose,
stopProp,
}: ExpandedBarProps) {
return (
<div id="text-input-bar" onPointerDown={stopProp} onPointerUp={stopProp}>
<textarea
ref={inputRef}
id="text-input"
placeholder="Type a message…"
disabled={disabled || sending}
value={text}
onInput={(e) => onInput((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
<div id="text-input-actions">
<button id="text-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="text-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
);
}
function useExpandState(onExpandChange?: (v: boolean) => void) {
const [expanded, setExpanded] = useState(false);
const set = useCallback(
(val: boolean) => {
setExpanded(val);
onExpandChange?.(val);
},
[onExpandChange],
);
return [expanded, set] as const;
}
export function TextInput({ disabled, onExpandChange }: TextInputProps) {
const [text, setText] = useState("");
const [expanded, setExpandedWithCb] = useExpandState(onExpandChange);
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const collapse = useCallback(() => {
setText("");
setExpandedWithCb(false);
inputRef.current?.blur();
}, [setExpandedWithCb]);
const send = useCallback(async () => {
const msg = text.trim();
if (!msg || sending) return;
setSending(true);
try {
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: msg }),
});
collapse();
} finally {
setSending(false);
}
}, [text, sending, collapse]);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
if (e.key === "Escape") collapse();
},
[send, collapse],
);
const onBlur = useCallback(() => {
if (text.trim().length === 0) {
setTimeout(() => {
if (document.activeElement !== inputRef.current) setExpandedWithCb(false);
}, 150);
}
}, [text]);
const stopProp = useCallback((e: Event) => e.stopPropagation(), []);
const expand = useCallback(() => {
setExpandedWithCb(true);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setExpandedWithCb]);
if (!expanded) {
return (
<button
id="text-compose-btn"
type="button"
aria-label="Type a message"
onPointerDown={stopProp}
onPointerUp={stopProp}
onClick={expand}
>
<ComposeIcon />
</button>
);
}
return (
<ExpandedBar
text={text}
disabled={disabled}
sending={sending}
inputRef={inputRef}
onInput={setText}
onKeyDown={onKeyDown}
onBlur={onBlur}
onSend={send}
onClose={collapse}
stopProp={stopProp}
/>
);
}

View file

@ -1,109 +0,0 @@
import { marked } from "marked";
import { useEffect, useRef, useState } from "preact/hooks";
import type { ToastItem } from "../types";
interface ToastProps {
toast: ToastItem;
onDismiss(id: number): void;
onChoice(requestId: string, value: string): void;
}
function Toast({ toast, onDismiss, onChoice }: ToastProps) {
const [dismissing, setDismissing] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismiss = () => {
if (dismissing) return;
setDismissing(true);
const t = setTimeout(() => onDismiss(toast.id), 400);
timerRef.current = t;
};
useEffect(() => {
if (toast.kind !== "choice" && toast.durationMs > 0) {
const t = setTimeout(dismiss, toast.durationMs);
timerRef.current = t;
return () => clearTimeout(t);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const bodyHtml = (): string => {
if (toast.kind === "choice") return "";
if (toast.kind === "image") return "";
const looksLikeHtml = /^\s*<[a-zA-Z]/.test(toast.content);
if (looksLikeHtml) return toast.content;
return marked.parse(toast.content) as string;
};
return (
<div class={`toast${dismissing ? " dismissing" : ""}`}>
<div class="toast-header">
{toast.title && <span class="toast-title">{toast.title}</span>}
<button
class="toast-close"
type="button"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
dismiss();
}}
>
×
</button>
</div>
{toast.kind === "image" && (
<img class="toast-image" src={toast.content} alt={toast.title || "image"} />
)}
{toast.kind === "text" && (
<div class="toast-body" dangerouslySetInnerHTML={{ __html: bodyHtml() }} />
)}
{toast.kind === "choice" && (
<>
<div class="toast-body">{toast.question}</div>
<div class="toast-choices">
{(toast.choices ?? []).map((label) => (
<button
key={label}
class="toast-choice-btn"
type="button"
onClick={(e) => {
e.stopPropagation();
onChoice(toast.requestId ?? "", label);
dismiss();
}}
>
{label}
</button>
))}
</div>
</>
)}
{toast.kind !== "choice" && toast.durationMs > 0 && (
<div class="toast-progress" style={{ animationDuration: `${toast.durationMs}ms` }} />
)}
</div>
);
}
interface ContainerProps {
toasts: ToastItem[];
onDismiss(id: number): void;
onChoice(requestId: string, value: string): void;
}
export function ToastContainer({ toasts, onDismiss, onChoice }: ContainerProps) {
return (
<div id="toast-container">
{toasts.map((t) => (
<Toast key={t.id} toast={t} onDismiss={onDismiss} onChoice={onChoice} />
))}
</div>
);
}

View file

@ -1,15 +1,20 @@
import { useCallback, useRef, useState } from "preact/hooks"; import { useCallback, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types"; import type { AgentState } from "../types";
const HOLD_MS = 300;
const MOVE_CANCEL_PX = 16;
interface UsePTTOptions { interface UsePTTOptions {
connected: boolean; connected: boolean;
onSendPtt(pressed: boolean): void; onSendPtt(pressed: boolean): void;
onBootstrap(): Promise<void>; onBootstrap(): Promise<void>;
onTap?(): void; // called on a short press (< HOLD_MS) that didn't activate PTT
} }
interface PTTState { interface PTTState {
agentStateOverride: AgentState | null; agentStateOverride: AgentState | null;
handlePointerDown(e: Event): Promise<void>; handlePointerDown(e: Event): Promise<void>;
handlePointerMove(e: Event): void;
handlePointerUp(e: Event): void; handlePointerUp(e: Event): void;
} }
@ -18,14 +23,18 @@ function dispatchMicEnable(enabled: boolean): void {
} }
/** Manages push-to-talk pointer events and mic enable/disable. */ /** Manages push-to-talk pointer events and mic enable/disable. */
export function usePTT({ connected, onSendPtt, onBootstrap }: UsePTTOptions): PTTState { export function usePTT({ connected, onSendPtt, onBootstrap, onTap }: UsePTTOptions): PTTState {
const [agentStateOverride, setAgentStateOverride] = useState<AgentState | null>(null); const [agentStateOverride, setAgentStateOverride] = useState<AgentState | null>(null);
const activePointers = useRef(new Set<number>()); const activePointers = useRef(new Set<number>());
const appStartedRef = useRef(false); const appStartedRef = useRef(false);
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pttFiredRef = useRef(false);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const beginPTT = useCallback(() => { const beginPTT = useCallback(() => {
if (!connected) return; if (!connected) return;
if (agentStateOverride === "listening") return; if (agentStateOverride === "listening") return;
pttFiredRef.current = true;
setAgentStateOverride("listening"); setAgentStateOverride("listening");
dispatchMicEnable(true); dispatchMicEnable(true);
onSendPtt(true); onSendPtt(true);
@ -52,19 +61,57 @@ export function usePTT({ connected, onSendPtt, onBootstrap }: UsePTTOptions): PT
appStartedRef.current = true; appStartedRef.current = true;
await onBootstrap(); await onBootstrap();
} }
if (activePointers.current.size === 1) beginPTT(); if (activePointers.current.size !== 1) return;
pttFiredRef.current = false;
pointerStartRef.current = { x: pe.clientX, y: pe.clientY };
// Delay activation slightly so horizontal swipe gestures can cancel.
holdTimerRef.current = setTimeout(beginPTT, HOLD_MS);
}, },
[onBootstrap, beginPTT], [onBootstrap, beginPTT],
); );
const handlePointerMove = useCallback((e: Event) => {
if (pttFiredRef.current) return;
if (holdTimerRef.current === null) return;
const pe = e as PointerEvent;
if (!activePointers.current.has(pe.pointerId)) return;
const start = pointerStartRef.current;
if (!start) return;
const dx = Math.abs(pe.clientX - start.x);
const dy = Math.abs(pe.clientY - start.y);
if (dx > MOVE_CANCEL_PX || dy > MOVE_CANCEL_PX) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
}, []);
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(e: Event) => { (e: Event) => {
const pe = e as PointerEvent; const pe = e as PointerEvent;
// Ignore pointers we never tracked (didn't hit a data-ptt target on down)
if (!activePointers.current.has(pe.pointerId)) return;
activePointers.current.delete(pe.pointerId); activePointers.current.delete(pe.pointerId);
if (activePointers.current.size === 0) endPTT(); if (activePointers.current.size !== 0) return;
// Cancel hold timer if it hasn't fired yet
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
pointerStartRef.current = null;
if (pttFiredRef.current) {
endPTT();
} else {
// PTT never fired → short tap.
onTap?.();
}
}, },
[endPTT], [endPTT, onTap],
); );
return { agentStateOverride, handlePointerDown, handlePointerUp }; return { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp };
} }

View file

@ -1,54 +0,0 @@
import { useCallback, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types";
export interface PushToTalkState {
pttPressed: boolean;
micStream: MediaStream | null;
beginPTT(): void;
endPTT(): void;
}
interface UsePushToTalkOptions {
connected: boolean;
agentState: AgentState;
onPttChange(pressed: boolean): void;
onSetAgentState(state: AgentState): void;
onShowStatus(text: string, persistMs?: number): void;
}
export function usePushToTalk({
connected,
onPttChange,
onSetAgentState,
onShowStatus,
}: UsePushToTalkOptions): PushToTalkState {
const [pttPressed, setPttPressed] = useState(false);
const micStreamRef = useRef<MediaStream | null>(null);
// Attach mic stream from RTCPeerConnection tracks — caller passes it via micStream prop
// Here we track from the parent. Mic enable/disable is done by the parent hook.
const beginPTT = useCallback(() => {
if (!connected) return;
if (pttPressed) return;
setPttPressed(true);
onPttChange(true);
onSetAgentState("listening");
onShowStatus("Listening...");
}, [connected, pttPressed, onPttChange, onSetAgentState, onShowStatus]);
const endPTT = useCallback(() => {
if (!pttPressed) return;
setPttPressed(false);
onPttChange(false);
onSetAgentState("idle");
if (connected) onShowStatus("Hold anywhere to talk", 1800);
}, [pttPressed, onPttChange, onSetAgentState, onShowStatus, connected]);
return {
pttPressed,
micStream: micStreamRef.current,
beginPTT,
endPTT,
};
}

View file

@ -1,160 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { AgentState, ClientMessage, LogLine, ServerMessage, ToastItem } from "../types"; import type {
AgentState,
CardItem,
CardLane,
CardMessageMetadata,
CardState,
ClientMessage,
LogLine,
ServerMessage,
} from "../types";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? ""; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
let toastIdCounter = 0; let cardIdCounter = 0;
let logIdCounter = 0; let logIdCounter = 0;
// --------------------------------------------------------------------------- const LANE_RANK: Record<CardLane, number> = {
// Types attention: 0,
// --------------------------------------------------------------------------- work: 1,
context: 2,
history: 3,
};
export interface WebRTCState { const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
interface WebRTCState {
connected: boolean; connected: boolean;
connecting: boolean; connecting: boolean;
textOnly: boolean;
agentState: AgentState; agentState: AgentState;
logLines: LogLine[]; logLines: LogLine[];
toasts: ToastItem[]; cards: CardItem[];
voiceStatus: string; voiceStatus: string;
statusVisible: boolean; statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null; remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null; remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void; sendJson(msg: ClientMessage): void;
dismissToast(id: number): void; sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
setTextOnly(enabled: boolean): void;
connect(): Promise<void>; connect(): Promise<void>;
} }
type AppendLine = (role: string, text: string, timestamp: string) => void; type AppendLine = (role: string, text: string, timestamp: string) => void;
type AddToast = (item: Omit<ToastItem, "id">) => number; type UpsertCard = (item: Omit<CardItem, "id">) => void;
type SetAgentState = (updater: (prev: AgentState) => AgentState) => void; type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
// --------------------------------------------------------------------------- interface RTCRefs {
// Message handlers (pure functions, outside hook to reduce complexity) pcRef: { current: RTCPeerConnection | null };
// --------------------------------------------------------------------------- dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
}
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 compareCards(a: CardItem, b: CardItem): number {
const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
if (laneDiff !== 0) return laneDiff;
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
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 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,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function handleTypedMessage( function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>, msg: Extract<ServerMessage, { type: string }>,
setAgentState: SetAgentState, setAgentState: SetAgentState,
appendLine: AppendLine, appendLine: AppendLine,
addToast: AddToast, upsertCard: UpsertCard,
idleFallback: IdleFallbackControls,
): void { ): void {
if (msg.type === "agent_state") { if (msg.type === "agent_state") {
const s = (msg as { type: "agent_state"; state: AgentState }).state; idleFallback.clear();
setAgentState((prev) => (prev === "listening" ? prev : s)); setAgentState((prev) => (prev === "listening" ? prev : msg.state));
return; return;
} }
if (msg.type === "message") { if (msg.type === "message") {
const mm = msg as { type: "message"; content: string; is_progress: boolean }; if (msg.is_tool_hint) {
if (!mm.is_progress) appendLine("nanobot", mm.content, ""); appendLine("tool", msg.content, msg.timestamp);
return; return;
} }
if (msg.type === "toast") { if (!msg.is_progress) {
const tm = msg as { appendLine(msg.role, msg.content, msg.timestamp);
type: "toast"; idleFallback.schedule();
kind: "text" | "image"; }
content: string;
title: string;
duration_ms: number;
};
addToast({
kind: tm.kind,
content: tm.content,
title: tm.title,
durationMs: tm.duration_ms ?? 6000,
});
return; return;
} }
if (msg.type === "choice") { if (msg.type === "card") {
const cm = msg as { upsertCard(toCardItem(msg));
type: "choice"; idleFallback.schedule();
request_id: string;
question: string;
choices: string[];
title: string;
};
addToast({
kind: "choice",
content: "",
title: cm.title || "",
durationMs: 0,
requestId: cm.request_id,
question: cm.question,
choices: cm.choices,
});
return; return;
} }
if (msg.type === "error") { if (msg.type === "error") {
appendLine("system", (msg as { type: "error"; error: string }).error, ""); appendLine("system", msg.error, "");
} idleFallback.schedule();
// pong and rtc-* are no-ops
}
function parseLegacyToast(text: string, addToast: AddToast): void {
console.log("[toast] parseLegacyToast raw text:", text);
try {
const t = JSON.parse(text);
console.log("[toast] parsed toast object:", t);
addToast({
kind: t.kind || "text",
content: t.content || "",
title: t.title || "",
durationMs: typeof t.duration_ms === "number" ? t.duration_ms : 6000,
});
} catch {
console.log("[toast] JSON parse failed, using raw text as content");
addToast({ kind: "text", content: text, title: "", durationMs: 6000 });
} }
} }
function parseLegacyChoice(text: string, addToast: AddToast): void {
try {
const c = JSON.parse(text);
addToast({
kind: "choice",
content: "",
title: c.title || "",
durationMs: 0,
requestId: c.request_id || "",
question: c.question || "",
choices: Array.isArray(c.choices) ? c.choices : [],
});
} catch {
/* ignore malformed */
}
}
function handleLegacyMessage(
rm: { role: string; text: string; timestamp?: string },
setAgentState: SetAgentState,
appendLine: AppendLine,
addToast: AddToast,
): void {
const role = (rm.role || "system").toString();
const text = (rm.text || "").toString();
const ts = rm.timestamp || "";
if (role === "agent-state") {
const newState = text.trim() as AgentState;
setAgentState((prev) => (prev === "listening" ? prev : newState));
return;
}
if (role === "toast") {
parseLegacyToast(text, addToast);
return;
}
if (role === "choice") {
parseLegacyChoice(text, addToast);
return;
}
if (role === "wisper") return; // suppress debug
appendLine(role, text, ts);
}
// ---------------------------------------------------------------------------
// WebRTC helpers
// ---------------------------------------------------------------------------
async function acquireMicStream(): Promise<MediaStream> { async function acquireMicStream(): Promise<MediaStream> {
try { try {
return await navigator.mediaDevices.getUserMedia({ return await navigator.mediaDevices.getUserMedia({
@ -185,7 +173,7 @@ function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
} }
}; };
pc.addEventListener("icegatheringstatechange", check); pc.addEventListener("icegatheringstatechange", check);
setTimeout(resolve, 5000); // safety timeout setTimeout(resolve, 5000);
}); });
} }
@ -202,28 +190,11 @@ async function exchangeSdp(
return resp.json() as Promise<{ sdp: string; rtcType: string }>; return resp.json() as Promise<{ sdp: string; rtcType: string }>;
} }
// --------------------------------------------------------------------------- async function runConnect(
// Hook internals refs: RTCRefs,
// --------------------------------------------------------------------------- cbs: RTCCallbacks,
opts: { textOnly: boolean },
interface RTCRefs { ): Promise<void> {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
}
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;
closePC: () => void;
}
async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
if (refs.pcRef.current) return; if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) { if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000); cbs.showStatus("WebRTC unavailable in this browser.", 4000);
@ -234,10 +205,12 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
let micStream: MediaStream | null = null; let micStream: MediaStream | null = null;
try { try {
if (!opts.textOnly) {
micStream = await acquireMicStream(); micStream = await acquireMicStream();
micStream.getAudioTracks().forEach((t) => { micStream.getAudioTracks().forEach((track) => {
t.enabled = false; track.enabled = false;
}); });
}
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
refs.pcRef.current = pc; refs.pcRef.current = pc;
@ -260,8 +233,9 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
dc.onopen = () => { dc.onopen = () => {
cbs.setConnected(true); cbs.setConnected(true);
cbs.setConnecting(false); cbs.setConnecting(false);
cbs.showStatus("Hold anywhere to talk", 2500); cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString()); cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
}; };
dc.onclose = () => { dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString()); cbs.appendLine("system", "Disconnected.", new Date().toISOString());
@ -269,11 +243,13 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
}; };
dc.onmessage = (e) => cbs.onDcMessage(e.data as string); dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
const stream = micStream; refs.micSendersRef.current = [];
stream.getAudioTracks().forEach((track) => { if (micStream) {
pc.addTrack(track, stream); micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
}); });
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio"); refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
@ -287,31 +263,114 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString()); cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000); cbs.showStatus("Connection failed.", 3000);
cbs.closePC(); cbs.closePC();
if (micStream) micStream?.getTracks().forEach((track) => {
micStream.getTracks().forEach((t) => { track.stop();
t.stop();
}); });
} }
} }
// --------------------------------------------------------------------------- function useBackendActions() {
// Message state sub-hook 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})`);
}, []);
interface MessageState { return { sendTextMessage };
agentState: AgentState;
logLines: LogLine[];
toasts: ToastItem[];
appendLine: AppendLine;
addToast: AddToast;
dismissToast: (id: number) => void;
onDcMessage: (raw: string) => void;
} }
function useMessageState(): MessageState { function useCardPolling(loadPersistedCards: () => Promise<void>) {
useEffect(() => {
loadPersistedCards().catch(() => {});
const pollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
}, 10000);
const onVisible = () => {
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
};
window.addEventListener("focus", onVisible);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(pollId);
window.removeEventListener("focus", onVisible);
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 useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle"); const [agentState, setAgentState] = useState<AgentState>("idle");
const [logLines, setLogLines] = useState<LogLine[]>([]); const [logLines, setLogLines] = useState<LogLine[]>([]);
const [toasts, setToasts] = useState<ToastItem[]>([]); const [cards, setCards] = useState<CardItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const appendLine = useCallback((role: string, text: string, timestamp: string) => { const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => { setLogLines((prev) => {
@ -323,144 +382,268 @@ function useMessageState(): MessageState {
}); });
}, []); }, []);
const addToast = useCallback((item: Omit<ToastItem, "id">) => { const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
const id = toastIdCounter++; setCards((prev) => {
setToasts((prev) => [{ ...item, id }, ...prev]); const existingIndex = item.serverId
return id; ? 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++ }]);
});
}, []); }, []);
const dismissToast = useCallback((id: number) => { const dismissCard = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id)); 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 clearIdleFallback = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
}, []);
const scheduleIdleFallback = useCallback(
(delayMs = 450) => {
clearIdleFallback();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clearIdleFallback],
);
useEffect(() => clearIdleFallback, [clearIdleFallback]);
const loadPersistedCards = useCallback(async () => {
try {
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;
}
const rawCards = (await resp.json()) as Array<
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" })
>;
setCards((prev) => {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
});
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, []); }, []);
const onDcMessage = useCallback( const onDcMessage = useCallback(
(raw: string) => { (raw: string) => {
console.log("[dc] onDcMessage raw:", raw);
let msg: ServerMessage; let msg: ServerMessage;
try { try {
msg = JSON.parse(raw); msg = JSON.parse(raw);
} catch { } catch {
console.log("[dc] JSON parse failed for raw message");
return; return;
} }
if ("type" in msg) { if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
console.log("[dc] typed message, type:", (msg as { type: string }).type);
handleTypedMessage( handleTypedMessage(
msg as Extract<ServerMessage, { type: string }>, msg as Extract<ServerMessage, { type: string }>,
setAgentState, setAgentState,
appendLine, appendLine,
addToast, upsertCard,
{ clear: clearIdleFallback, schedule: scheduleIdleFallback },
); );
} else {
console.log("[dc] legacy message, role:", (msg as { role: string }).role);
handleLegacyMessage(
msg as { role: string; text: string; timestamp?: string },
setAgentState,
appendLine,
addToast,
);
}
}, },
[appendLine, addToast], [appendLine, clearIdleFallback, scheduleIdleFallback, upsertCard],
); );
return { agentState, logLines, toasts, appendLine, addToast, dismissToast, onDcMessage }; return { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage };
} }
// --------------------------------------------------------------------------- function usePeerConnectionControls({
// Hook textOnly,
// --------------------------------------------------------------------------- connected,
appendLine,
export function useWebRTC(): WebRTCState { onDcMessage,
const [connected, setConnected] = useState(false); loadPersistedCards,
const [connecting, setConnecting] = useState(false); showStatus,
const [voiceStatus, setVoiceStatus] = useState(""); refs,
const [statusVisible, setStatusVisible] = useState(false); setConnected,
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null); setConnecting,
setRemoteStream,
const pcRef = useRef<RTCPeerConnection | null>(null); textOnlyRef,
const dcRef = useRef<RTCDataChannel | null>(null); }: {
const remoteAudioRef = useRef<HTMLAudioElement | null>(null); textOnly: boolean;
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); connected: boolean;
const micSendersRef = useRef<RTCRtpSender[]>([]); appendLine: AppendLine;
onDcMessage: (raw: string) => void;
const { agentState, logLines, toasts, appendLine, dismissToast, onDcMessage } = useMessageState(); loadPersistedCards: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
// Create audio element once refs: RTCRefs;
useEffect(() => { setConnected: (value: boolean) => void;
const audio = new Audio(); setConnecting: (value: boolean) => void;
audio.autoplay = true; setRemoteStream: (stream: MediaStream | null) => void;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true; textOnlyRef: { current: boolean };
remoteAudioRef.current = audio; }) {
return () => {
audio.srcObject = null;
};
}, []);
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;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, []);
const showStatus = useCallback((text: string, persistMs = 0) => {
setVoiceStatus(text);
setStatusVisible(true);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
if (persistMs > 0) {
statusTimerRef.current = setTimeout(() => setStatusVisible(false), persistMs);
}
}, []);
const sendJson = useCallback((msg: ClientMessage) => {
const dc = dcRef.current;
if (!dc || dc.readyState !== "open") return;
dc.send(JSON.stringify(msg));
}, []);
const closePC = useCallback(() => { const closePC = useCallback(() => {
dcRef.current?.close(); refs.dcRef.current?.close();
dcRef.current = null; refs.dcRef.current = null;
pcRef.current?.close(); refs.pcRef.current?.close();
pcRef.current = null; refs.pcRef.current = null;
micSendersRef.current = []; refs.micSendersRef.current = [];
setConnected(false); setConnected(false);
setConnecting(false); setConnecting(false);
if (remoteAudioRef.current) remoteAudioRef.current.srcObject = null; if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null); setRemoteStream(null);
}, []); }, [refs, setConnected, setConnecting, setRemoteStream]);
const connect = useCallback(async () => { const connect = useCallback(async () => {
const refs: RTCRefs = { pcRef, dcRef, remoteAudioRef, micSendersRef }; await runConnect(
const cbs: RTCCallbacks = { refs,
{
setConnected, setConnected,
setConnecting, setConnecting,
setRemoteStream, setRemoteStream,
showStatus, showStatus,
appendLine, appendLine,
onDcMessage, onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
},
closePC, 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]);
return { closePC, connect };
}
export function useWebRTC(): WebRTCState {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [textOnly, setTextOnlyState] = 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[]>([]),
}; };
await runConnect(refs, cbs); const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
}, [setConnected, setConnecting, setRemoteStream, showStatus, appendLine, onDcMessage, closePC]); const textOnlyRef = useRef(false);
const { sendTextMessage } = useBackendActions();
const { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage } =
useMessageState();
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)
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));
},
[refs.dcRef],
);
useCardPolling(loadPersistedCards);
useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef: refs.remoteAudioRef,
micSendersRef: refs.micSendersRef,
dcRef: refs.dcRef,
textOnlyRef,
});
const { connect } = usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
});
return { return {
connected, connected,
connecting, connecting,
textOnly,
agentState, agentState,
logLines, logLines,
toasts, cards,
voiceStatus, voiceStatus,
statusVisible, statusVisible,
remoteAudioEl: remoteAudioRef.current, remoteAudioEl: refs.remoteAudioRef.current,
remoteStream, remoteStream,
sendJson, sendJson,
dismissToast, sendTextMessage,
dismissCard,
setTextOnly,
connect, connect,
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,62 @@
// Shared TypeScript types for the Nanobot UI // Shared TypeScript types for the Nanobot UI
export type AgentState = "idle" | "listening" | "thinking" | "speaking"; export type AgentState = "idle" | "listening" | "thinking" | "speaking";
export type CardLane = "attention" | "work" | "context" | "history";
export type CardState = "active" | "stale" | "resolved" | "superseded" | "archived";
export type JsonValue =
| string
| number
| boolean
| null
| { [key: string]: JsonValue }
| JsonValue[];
export interface CardMessageMetadata {
card_id?: string;
card_slot?: string;
card_title?: string;
card_lane?: CardLane;
card_template_key?: string;
card_context_summary?: string;
card_response_value?: string;
card_live_content?: JsonValue;
}
// Messages sent FROM backend TO frontend via DataChannel
export type ServerMessage = export type ServerMessage =
| { type: "agent_state"; state: AgentState } | { type: "agent_state"; state: AgentState }
| { type: "message"; content: string; is_progress: boolean } | {
| { type: "toast"; kind: "text" | "image"; content: string; title: string; duration_ms: number } type: "message";
| { type: "choice"; request_id: string; question: string; choices: string[]; title: string } role: string;
content: string;
is_progress: boolean;
is_tool_hint: boolean;
timestamp: string;
}
| {
type: "card";
id: string;
kind: "text" | "question";
title: string;
content: string;
question: string;
choices: string[];
response_value: string;
slot: string;
lane: CardLane;
priority: number;
state: CardState;
template_key: string;
context_summary: string;
created_at: string;
updated_at: string;
}
| { type: "error"; error: string } | { type: "error"; error: string }
| { type: "pong" } | { type: "pong" };
// Legacy wire format still used by backend (role-based)
| { role: string; text: string; timestamp?: string };
// Messages sent FROM frontend TO backend via DataChannel
export type ClientMessage = export type ClientMessage =
| { type: "voice-ptt"; pressed: boolean } | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }
| { type: "command"; command: string } | { type: "command"; command: string }
| { type: "ui-response"; request_id: string; value: string } | { type: "card-response"; card_id: string; value: string }
| { type: "ping" }; | { type: "ping" };
export interface LogLine { export interface LogLine {
@ -27,19 +66,21 @@ export interface LogLine {
timestamp: string; timestamp: string;
} }
export interface ToastItem { export interface CardItem {
id: number; id: number;
kind: "text" | "image" | "choice"; serverId?: string;
kind: "text" | "question";
content: string; content: string;
title: string; title: string;
durationMs: number;
// For choice toasts
requestId?: string;
question?: string; question?: string;
choices?: string[]; choices?: string[];
} responseValue?: string;
slot?: string;
export interface RTCState { lane: CardLane;
connected: boolean; priority: number;
connecting: boolean; state: CardState;
templateKey?: string;
contextSummary?: string;
createdAt: string;
updatedAt: string;
} }

View file

@ -1,31 +1,28 @@
"""SuperTonic Gateway nanobot integration for the web UI. """SuperTonic Gateway - nanobot integration for the web UI.
Connects to the already-running nanobot process via a Unix domain socket. Connects to the already-running nanobot process via a Unix domain socket.
nanobot must be started separately (e.g. ``nanobot gateway``) with the API nanobot must be started separately (e.g. ``nanobot gateway``) with the API
channel enabled in its config. channel enabled in its config.
Wire protocol (newline-delimited JSON) Wire protocol (newline-delimited JSON-RPC 2.0)
--------------------------------------- -----------------------------------------------
Client nanobot:: Client -> nanobot notifications::
{"type": "message", "content": "hello", "chat_id": "web"} {"jsonrpc": "2.0", "method": "message.send",
{"type": "ping"} "params": {"content": "hello", "chat_id": "web", "metadata": {}}}
{"type": "ui-response", "request_id": "<uuid>", "value": "Option A", "chat_id": "web"} {"jsonrpc": "2.0", "method": "card.respond",
{"type": "command", "command": "reset", "chat_id": "web"} "params": {"card_id": "card_123", "value": "Option A"}}
{"jsonrpc": "2.0", "method": "command.execute",
"params": {"command": "reset", "chat_id": "web"}}
nanobot client:: nanobot -> client notifications::
{"type": "message", "content": "Hi!", "chat_id": "web", "is_progress": false} {"jsonrpc": "2.0", "method": "message",
{"type": "agent_state", "state": "thinking", "chat_id": "web"} "params": {"content": "Hi!", "chat_id": "web", "is_progress": false}}
{"type": "toast", "kind": "text"|"image", "content": "...", "title": "...", "duration_ms": 5000} {"jsonrpc": "2.0", "method": "agent_state",
{"type": "choice", "request_id": "<uuid>", "question": "...", "choices": ["A", "B"], "params": {"state": "thinking", "chat_id": "web"}}
"title": "...", "chat_id": "web"} {"jsonrpc": "2.0", "method": "card",
{"type": "pong"} "params": {"id": "card_123", "kind": "text", "title": "Weather", "lane": "context"}}
{"type": "error", "error": "..."}
The public ``SuperTonicGateway`` interface (``spawn_tui``, ``send_user_message``,
``stop_tui``, ``shutdown``) is unchanged so ``app.py`` and ``voice_rtc.py``
require no modification.
""" """
from __future__ import annotations from __future__ import annotations
@ -34,27 +31,30 @@ import asyncio
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
from wisper import WisperBus, WisperEvent from wisper import WisperBus, WisperEvent
# Default path — must match nanobot's channels.api.socket_path config value.
DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock" DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock"
_JSONRPC_VERSION = "2.0"
# --------------------------------------------------------------------------- def _encode(obj: dict[str, Any]) -> bytes:
# NanobotApiProcess — connects to the running nanobot via its Unix socket return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
# ---------------------------------------------------------------------------
def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"jsonrpc": _JSONRPC_VERSION,
"method": method,
}
if params is not None:
payload["params"] = params
return payload
class NanobotApiProcess: class NanobotApiProcess:
"""Connects to the running nanobot process via its Unix domain socket. """Connects to the running nanobot process via its Unix domain socket."""
Lifecycle
---------
``start()`` opens a connection to nanobot's API socket.
``send()`` writes a user message over the socket.
``stop()`` closes the connection.
"""
def __init__(self, bus: WisperBus, socket_path: Path) -> None: def __init__(self, bus: WisperBus, socket_path: Path) -> None:
self._bus = bus self._bus = bus
@ -74,9 +74,7 @@ class NanobotApiProcess:
async def start(self) -> None: async def start(self) -> None:
if self.running: if self.running:
await self._bus.publish( await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
WisperEvent(role="system", text="Already connected to nanobot.")
)
return return
if not self._socket_path.exists(): if not self._socket_path.exists():
@ -99,64 +97,57 @@ class NanobotApiProcess:
) )
except OSError as exc: except OSError as exc:
await self._bus.publish( await self._bus.publish(
WisperEvent( WisperEvent(role="system", text=f"Could not connect to nanobot API socket: {exc}")
role="system",
text=f"Could not connect to nanobot API socket: {exc}",
)
) )
return return
self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader") self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader")
await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot."))
async def send(self, text: str) -> None: async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None:
if not self.running or self._writer is None: if not self.running or self._writer is None:
await self._bus.publish( await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return return
payload = json.dumps({"type": "message", "content": text, "chat_id": "web"}) + "\n"
try: try:
self._writer.write(payload.encode()) await self._send_notification(
await self._writer.drain() "message.send",
{
"content": text,
"chat_id": "web",
"metadata": dict(metadata or {}),
},
)
except OSError as exc: except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup() await self._cleanup()
async def send_ui_response(self, request_id: str, value: str) -> None: async def send_card_response(self, card_id: str, value: str) -> None:
"""Forward a ui-response (choice selection) back to nanobot."""
if not self.running or self._writer is None: if not self.running or self._writer is None:
return return
payload = (
json.dumps(
{"type": "ui-response", "request_id": request_id, "value": value, "chat_id": "web"}
)
+ "\n"
)
try: try:
self._writer.write(payload.encode()) await self._send_notification(
await self._writer.drain() "card.respond",
{
"card_id": card_id,
"value": value,
},
)
except OSError as exc: except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup() await self._cleanup()
async def send_command(self, command: str) -> None: async def send_command(self, command: str) -> None:
"""Send a command (e.g. 'reset') to nanobot."""
if not self.running or self._writer is None: if not self.running or self._writer is None:
await self._bus.publish( await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return return
payload = json.dumps({"type": "command", "command": command, "chat_id": "web"}) + "\n"
try: try:
self._writer.write(payload.encode()) await self._send_notification(
await self._writer.drain() "command.execute",
{
"command": command,
"chat_id": "web",
},
)
except OSError as exc: except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup() await self._cleanup()
@ -165,10 +156,6 @@ class NanobotApiProcess:
await self._cleanup() await self._cleanup()
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
async def _cleanup(self) -> None: async def _cleanup(self) -> None:
if self._read_task and not self._read_task.done(): if self._read_task and not self._read_task.done():
self._read_task.cancel() self._read_task.cancel()
@ -187,8 +174,12 @@ class NanobotApiProcess:
self._writer = None self._writer = None
self._reader = None self._reader = None
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
assert self._writer is not None
self._writer.write(_encode(_jsonrpc_notification(method, params)))
await self._writer.drain()
async def _read_loop(self) -> None: async def _read_loop(self) -> None:
"""Read newline-delimited JSON from nanobot and publish WisperEvents."""
assert self._reader is not None assert self._reader is not None
try: try:
while True: while True:
@ -197,13 +188,10 @@ class NanobotApiProcess:
except OSError: except OSError:
break break
if not line: if not line:
break # EOF — nanobot closed the connection break
await self._handle_line(line) await self._handle_line(line)
finally: finally:
await self._bus.publish( await self._bus.publish(WisperEvent(role="system", text="Nanobot closed the connection."))
WisperEvent(role="system", text="Nanobot closed the connection.")
)
# Clear writer so running → False
self._writer = None self._writer = None
self._reader = None self._reader = None
@ -219,43 +207,39 @@ class NanobotApiProcess:
) )
return return
msg_type = str(obj.get("type", "")) if not isinstance(obj, dict) or obj.get("jsonrpc") != _JSONRPC_VERSION:
await self._bus.publish(
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
)
return
if msg_type == "message": if "method" not in obj:
content = str(obj.get("content", "")) error = obj.get("error")
is_progress = bool(obj.get("is_progress", False)) if isinstance(error, dict):
message = str(error.get("message", "Unknown JSON-RPC error"))
await self._bus.publish(WisperEvent(role="system", text=f"Nanobot error: {message}"))
return
method = str(obj.get("method", "")).strip()
params = obj.get("params", {})
if params is None or not isinstance(params, dict):
params = {}
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))
if is_progress: if is_progress:
# Intermediate tool-call hint — show in UI, skip TTS role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
await self._bus.publish(WisperEvent(role="nanobot-progress", text=content)) await self._bus.publish(WisperEvent(role=role, text=content))
else: else:
# Final answer — display + TTS
await self._bus.publish(WisperEvent(role="nanobot", text=content)) await self._bus.publish(WisperEvent(role="nanobot", text=content))
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content)) await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
elif method == "agent_state":
elif msg_type == "agent_state": state = str(params.get("state", ""))
state = str(obj.get("state", ""))
await self._bus.publish(WisperEvent(role="agent-state", text=state)) await self._bus.publish(WisperEvent(role="agent-state", text=state))
elif method == "card":
elif msg_type == "toast": await self._bus.publish(WisperEvent(role="card", text=json.dumps(params)))
# Forward the full toast payload as JSON so the frontend can render it.
await self._bus.publish(WisperEvent(role="toast", text=json.dumps(obj)))
elif msg_type == "choice":
# Forward the full choice payload as JSON so the frontend can render it.
await self._bus.publish(WisperEvent(role="choice", text=json.dumps(obj)))
elif msg_type == "pong":
pass # keepalive, ignore
elif msg_type == "error":
await self._bus.publish(
WisperEvent(role="system", text=f"Nanobot error: {obj.get('error', '')}")
)
# ---------------------------------------------------------------------------
# SuperTonicGateway — public interface (unchanged from original)
# ---------------------------------------------------------------------------
class SuperTonicGateway: class SuperTonicGateway:
@ -272,51 +256,40 @@ class SuperTonicGateway:
async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None: async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None:
await self.bus.unsubscribe(queue) await self.bus.unsubscribe(queue)
async def spawn_tui(self) -> None: async def connect_nanobot(self) -> None:
"""Connect to nanobot (name kept for API compatibility with app.py)."""
async with self._lock: async with self._lock:
if self._process and self._process.running: if self._process and self._process.running:
await self.bus.publish( await self.bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
WisperEvent(role="system", text="Already connected to nanobot.")
)
return return
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path) self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
await self._process.start() await self._process.start()
async def send_user_message(self, text: str) -> None: async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> None:
message = text.strip() message = text.strip()
if not message: if not message:
return return
await self.bus.publish(WisperEvent(role="user", text=message)) await self.bus.publish(WisperEvent(role="user", text=message))
async with self._lock: async with self._lock:
if not self._process: if not self._process:
await self.bus.publish( await self.bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return return
await self._process.send(message) await self._process.send(message, metadata=metadata)
async def send_ui_response(self, request_id: str, value: str) -> None: async def send_card_response(self, card_id: str, value: str) -> None:
"""Forward a choice selection back to nanobot."""
async with self._lock: async with self._lock:
if self._process: if self._process:
await self._process.send_ui_response(request_id, value) await self._process.send_card_response(card_id, value)
async def send_command(self, command: str) -> None: async def send_command(self, command: str) -> None:
"""Send a command (e.g. 'reset') to nanobot."""
async with self._lock: async with self._lock:
if self._process: if self._process:
await self._process.send_command(command) await self._process.send_command(command)
async def stop_tui(self) -> None: async def disconnect_nanobot(self) -> None:
"""Disconnect from nanobot (name kept for API compatibility with app.py)."""
async with self._lock: async with self._lock:
if self._process: if self._process:
await self._process.stop() await self._process.stop()
self._process = None self._process = None
async def shutdown(self) -> None: async def shutdown(self) -> None:
await self.stop_tui() await self.disconnect_nanobot()

View file

@ -64,7 +64,7 @@ except Exception: # pragma: no cover - runtime fallback when aiortc is unavaila
SPEECH_FILTER_RE = re.compile( SPEECH_FILTER_RE = re.compile(
r"^(spawned nanobot tui|stopped nanobot tui|nanobot tui exited|websocket)", r"^(already connected to nanobot|connected to nanobot|disconnected from nanobot|nanobot closed the connection|websocket)",
re.IGNORECASE, re.IGNORECASE,
) )
THINKING_STATUS_RE = re.compile( THINKING_STATUS_RE = re.compile(
@ -96,6 +96,38 @@ def _sanitize_tts_text(text: str) -> str:
return cleaned return cleaned
def _coerce_message_metadata(raw: Any) -> dict[str, Any]:
def _coerce_jsonish(value: Any, depth: int = 0) -> Any:
if depth > 6:
return None
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
cleaned_dict: dict[str, Any] = {}
for key, item in value.items():
cleaned_item = _coerce_jsonish(item, depth + 1)
if cleaned_item is not None:
cleaned_dict[str(key)] = cleaned_item
return cleaned_dict
if isinstance(value, list):
cleaned_list: list[Any] = []
for item in value[:50]:
cleaned_item = _coerce_jsonish(item, depth + 1)
if cleaned_item is not None:
cleaned_list.append(cleaned_item)
return cleaned_list
return None
if not isinstance(raw, dict):
return {}
cleaned: dict[str, Any] = {}
for key, value in raw.items():
cleaned_value = _coerce_jsonish(value)
if cleaned_value is not None:
cleaned[str(key)] = cleaned_value
return cleaned
def _optional_int_env(name: str) -> int | None: def _optional_int_env(name: str) -> int | None:
raw_value = os.getenv(name, "").strip() raw_value = os.getenv(name, "").strip()
if not raw_value: if not raw_value:
@ -876,6 +908,7 @@ class WebRTCVoiceSession:
) )
self._last_stt_backlog_notice_at = 0.0 self._last_stt_backlog_notice_at = 0.0
self._ptt_pressed = False self._ptt_pressed = False
self._active_message_metadata: dict[str, Any] = {}
def set_push_to_talk_pressed(self, pressed: bool) -> None: def set_push_to_talk_pressed(self, pressed: bool) -> None:
self._ptt_pressed = bool(pressed) self._ptt_pressed = bool(pressed)
@ -917,12 +950,17 @@ class WebRTCVoiceSession:
await self._close_peer_connection() await self._close_peer_connection()
self._ptt_pressed = False self._ptt_pressed = False
self._active_message_metadata = {}
peer_connection = RTCPeerConnection() peer_connection = RTCPeerConnection()
self._pc = peer_connection self._pc = peer_connection
offer_has_audio = bool(re.search(r"(?im)^m=audio\s", sdp))
if offer_has_audio:
self._outbound_track = QueueAudioTrack() self._outbound_track = QueueAudioTrack()
self._outbound_track._on_playing_changed = self._on_track_playing_changed self._outbound_track._on_playing_changed = self._on_track_playing_changed
peer_connection.addTrack(self._outbound_track) peer_connection.addTrack(self._outbound_track)
else:
self._outbound_track = None
@peer_connection.on("datachannel") @peer_connection.on("datachannel")
def on_datachannel(channel: Any) -> None: def on_datachannel(channel: Any) -> None:
@ -938,13 +976,14 @@ class WebRTCVoiceSession:
return return
msg_type = str(msg.get("type", "")).strip() msg_type = str(msg.get("type", "")).strip()
if msg_type == "voice-ptt": if msg_type == "voice-ptt":
self._active_message_metadata = _coerce_message_metadata(msg.get("metadata", {}))
self.set_push_to_talk_pressed(bool(msg.get("pressed", False))) self.set_push_to_talk_pressed(bool(msg.get("pressed", False)))
elif msg_type == "command": elif msg_type == "command":
asyncio.create_task(self._gateway.send_command(str(msg.get("command", "")))) asyncio.create_task(self._gateway.send_command(str(msg.get("command", ""))))
elif msg_type == "ui-response": elif msg_type == "card-response":
asyncio.create_task( asyncio.create_task(
self._gateway.send_ui_response( self._gateway.send_card_response(
str(msg.get("request_id", "")), str(msg.get("card_id", "")),
str(msg.get("value", "")), str(msg.get("value", "")),
) )
) )
@ -1274,7 +1313,10 @@ class WebRTCVoiceSession:
await self._gateway.bus.publish( await self._gateway.bus.publish(
WisperEvent(role="wisper", text=f"voice transcript: {transcript}") WisperEvent(role="wisper", text=f"voice transcript: {transcript}")
) )
await self._gateway.send_user_message(transcript) await self._gateway.send_user_message(
transcript,
metadata=dict(self._active_message_metadata),
)
async def _close_peer_connection(self) -> None: async def _close_peer_connection(self) -> None:
self._dc = None self._dc = None