chore: snapshot current state before cleanup

This commit is contained in:
kacper 2026-03-14 12:10:39 -04:00
parent db4ce8b14f
commit 94e62c9456
14 changed files with 489 additions and 3929 deletions

197
app.py
View file

@ -5,6 +5,7 @@ import json
import os import os
import re import re
import shutil import shutil
import subprocess
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -35,6 +36,8 @@ NANOBOT_SCRIPT_WORKSPACE = Path(
CARDS_ROOT = NANOBOT_WORKSPACE / "cards" CARDS_ROOT = NANOBOT_WORKSPACE / "cards"
CARD_INSTANCES_DIR = CARDS_ROOT / "instances" CARD_INSTANCES_DIR = CARDS_ROOT / "instances"
CARD_TEMPLATES_DIR = CARDS_ROOT / "templates" CARD_TEMPLATES_DIR = CARDS_ROOT / "templates"
CARD_SOURCES_DIR = CARDS_ROOT / "sources"
CARD_SOURCE_STATE_DIR = CARDS_ROOT / "source-state"
TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md" TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md"
MAX_TEMPLATES_IN_PROMPT = 12 MAX_TEMPLATES_IN_PROMPT = 12
MAX_TEMPLATE_HTML_CHARS = 4000 MAX_TEMPLATE_HTML_CHARS = 4000
@ -46,6 +49,8 @@ _MAX_SCRIPT_PROXY_ARGS = 16
_MAX_SCRIPT_PROXY_STDERR_CHARS = 2000 _MAX_SCRIPT_PROXY_STDERR_CHARS = 2000
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
CARD_SOURCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_SOURCE_STATE_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Nanobot SuperTonic Wisper Web") app = FastAPI(title="Nanobot SuperTonic Wisper Web")
@ -525,6 +530,155 @@ def _normalize_home_assistant_proxy_path(target_path: str) -> str:
return f"/api{normalized}" return f"/api{normalized}"
def _run_workspace_script(script_file: Path, args: list[str], *, timeout_seconds: float) -> tuple[int, str, str]:
process = subprocess.run(
[sys.executable, str(script_file), *args],
cwd=str(script_file.parent),
capture_output=True,
text=True,
timeout=timeout_seconds,
)
return process.returncode, process.stdout.strip(), process.stderr.strip()
def _card_source_state_path(source_id: str) -> Path:
return CARD_SOURCE_STATE_DIR / f"{source_id}.json"
def _load_card_source_configs() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for path in sorted(CARD_SOURCES_DIR.glob('*.json')):
try:
raw = json.loads(path.read_text(encoding='utf-8'))
except Exception:
continue
if not isinstance(raw, dict):
continue
source_id = _normalize_card_id(str(raw.get('id') or path.stem))
if not source_id or raw.get('enabled', True) is False:
continue
script = str(raw.get('script', '')).strip()
if not script:
continue
raw_args = raw.get('args', [])
if not isinstance(raw_args, list):
raw_args = []
try:
min_interval_ms = max(0, int(raw.get('min_interval_ms', 10000)))
except (TypeError, ValueError):
min_interval_ms = 10000
try:
timeout_seconds = max(1, min(300, int(raw.get('timeout_seconds', 60))))
except (TypeError, ValueError):
timeout_seconds = 60
rows.append({
'id': source_id,
'script': script,
'args': [str(arg) for arg in raw_args][: _MAX_SCRIPT_PROXY_ARGS],
'min_interval_ms': min_interval_ms,
'timeout_seconds': timeout_seconds,
})
return rows
def _load_card_source_state(source_id: str) -> dict[str, Any]:
path = _card_source_state_path(source_id)
try:
payload = json.loads(path.read_text(encoding='utf-8'))
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
def _save_card_source_state(source_id: str, payload: dict[str, Any]) -> None:
_card_source_state_path(source_id).write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + '\n',
encoding='utf-8',
)
def _sync_card_sources(*, force: bool = False, source_id: str | None = None) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
results: list[dict[str, Any]] = []
for config in _load_card_source_configs():
current_id = str(config.get('id', ''))
if source_id and current_id != source_id:
continue
state = _load_card_source_state(current_id)
last_completed_raw = str(state.get('last_completed_at', '') or '')
should_run = force
if not should_run:
if not last_completed_raw:
should_run = True
else:
try:
last_completed = datetime.fromisoformat(last_completed_raw)
elapsed_ms = (now - last_completed).total_seconds() * 1000
should_run = elapsed_ms >= int(config.get('min_interval_ms', 10000))
except ValueError:
should_run = True
if not should_run:
results.append({'id': current_id, 'status': 'skipped'})
continue
try:
script_file = _resolve_workspace_script(str(config.get('script', '')))
returncode, stdout_text, stderr_text = _run_workspace_script(
script_file,
list(config.get('args', [])),
timeout_seconds=float(config.get('timeout_seconds', 60)),
)
runtime = {
'id': current_id,
'last_started_at': now.isoformat(),
'last_completed_at': datetime.now(timezone.utc).isoformat(),
'last_return_code': returncode,
'script': str(config.get('script', '')),
'args': list(config.get('args', [])),
}
if returncode != 0:
runtime['last_error'] = stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS]
_save_card_source_state(current_id, runtime)
results.append({'id': current_id, 'status': 'error', 'error': runtime['last_error']})
continue
parsed_stdout: Any = None
if stdout_text:
try:
parsed_stdout = json.loads(stdout_text)
except json.JSONDecodeError:
parsed_stdout = stdout_text
runtime['last_result'] = parsed_stdout
runtime.pop('last_error', None)
_save_card_source_state(current_id, runtime)
results.append({'id': current_id, 'status': 'synced', 'result': parsed_stdout})
except subprocess.TimeoutExpired:
runtime = {
'id': current_id,
'last_started_at': now.isoformat(),
'last_completed_at': datetime.now(timezone.utc).isoformat(),
'last_return_code': -1,
'last_error': 'card source timed out',
'script': str(config.get('script', '')),
'args': list(config.get('args', [])),
}
_save_card_source_state(current_id, runtime)
results.append({'id': current_id, 'status': 'error', 'error': 'card source timed out'})
except Exception as exc:
runtime = {
'id': current_id,
'last_started_at': now.isoformat(),
'last_completed_at': datetime.now(timezone.utc).isoformat(),
'last_return_code': -1,
'last_error': str(exc),
'script': str(config.get('script', '')),
'args': list(config.get('args', [])),
}
_save_card_source_state(current_id, runtime)
results.append({'id': current_id, 'status': 'error', 'error': str(exc)})
return results
def _resolve_workspace_script(script_path: str) -> Path: def _resolve_workspace_script(script_path: str) -> Path:
normalized = script_path.strip().lstrip("/") normalized = script_path.strip().lstrip("/")
if not normalized: if not normalized:
@ -631,33 +785,26 @@ async def workspace_script_proxy(script_path: str, request: Request) -> JSONResp
return JSONResponse({"error": str(exc)}, status_code=400) return JSONResponse({"error": str(exc)}, status_code=400)
try: try:
process = await asyncio.create_subprocess_exec( returncode, stdout_text, stderr_text = await asyncio.to_thread(
sys.executable, _run_workspace_script,
str(script_file), script_file,
*args, args,
cwd=str(script_file.parent), timeout_seconds=60.0,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0) except subprocess.TimeoutExpired:
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
return JSONResponse({"error": "script execution timed out"}, status_code=504) return JSONResponse({"error": "script execution timed out"}, status_code=504)
except OSError as exc: except OSError as exc:
return JSONResponse({"error": f"failed to start script: {exc}"}, status_code=502) return JSONResponse({"error": f"failed to start script: {exc}"}, status_code=502)
stderr_text = stderr.decode("utf-8", errors="replace").strip() if returncode != 0:
if process.returncode != 0:
return JSONResponse( return JSONResponse(
{ {
"error": f"script exited with code {process.returncode}", "error": f"script exited with code {returncode}",
"stderr": stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS], "stderr": stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS],
}, },
status_code=502, status_code=502,
) )
stdout_text = stdout.decode("utf-8", errors="replace").strip()
try: try:
payload = json.loads(stdout_text) payload = json.loads(stdout_text)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
@ -674,9 +821,26 @@ async def workspace_script_proxy(script_path: str, request: Request) -> JSONResp
@app.get("/cards") @app.get("/cards")
async def get_cards() -> JSONResponse: async def get_cards() -> JSONResponse:
_sync_card_sources()
return JSONResponse(_load_cards()) return JSONResponse(_load_cards())
@app.post("/cards/sync")
async def sync_cards_endpoint(request: Request) -> JSONResponse:
try:
payload = await request.json()
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
raw_source_id = str(payload.get('source_id', '')).strip()
source_id = _normalize_card_id(raw_source_id) if raw_source_id else ''
if raw_source_id and not source_id:
return JSONResponse({'error': 'invalid source id'}, status_code=400)
results = _sync_card_sources(force=True, source_id=source_id or None)
return JSONResponse({'status': 'ok', 'results': results})
@app.delete("/cards/{card_id}") @app.delete("/cards/{card_id}")
async def delete_card(card_id: str) -> JSONResponse: async def delete_card(card_id: str) -> JSONResponse:
if not _normalize_card_id(card_id): if not _normalize_card_id(card_id):
@ -761,7 +925,10 @@ async def post_message(request: Request) -> JSONResponse:
return JSONResponse({"error": "empty message"}, status_code=400) return JSONResponse({"error": "empty message"}, status_code=400)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
metadata = {} metadata = {}
try:
await gateway.send_user_message(text, metadata=metadata) await gateway.send_user_message(text, metadata=metadata)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})

View file

@ -5,6 +5,7 @@
"": { "": {
"name": "nanobot-ui", "name": "nanobot-ui",
"dependencies": { "dependencies": {
"@fontsource/iosevka": "^5.2.5",
"marked": "^12.0.0", "marked": "^12.0.0",
"preact": "^10.22.0", "preact": "^10.22.0",
"three": "^0.165.0", "three": "^0.165.0",
@ -132,6 +133,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@fontsource/iosevka": ["@fontsource/iosevka@5.2.5", "", {}, "sha512-Zv/UHJodDug1LcnWv2u2+GPp3oWP3U6Xp16cJOsqqZQNsCu8sA/ttT331N0NypxBZ+7c8szlSRlYDcy9liZ8pw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Nanobot</title> <title>Nanobot</title>
<script type="module" crossorigin src="/assets/index-D7b7A0h0.js"></script> <script type="module" crossorigin src="/assets/index-CAK37B_S.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IlzmBIpb.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dz_SN8B6.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -12,6 +12,7 @@
"check": "biome check ./src" "check": "biome check ./src"
}, },
"dependencies": { "dependencies": {
"@fontsource/iosevka": "^5.2.5",
"marked": "^12.0.0", "marked": "^12.0.0",
"preact": "^10.22.0", "preact": "^10.22.0",
"three": "^0.165.0" "three": "^0.165.0"

View file

@ -6,10 +6,11 @@ import { LogPanel } from "./components/LogPanel";
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"; import type { CardItem, CardMessageMetadata, CardSelectionRange, JsonValue } from "./types";
const SWIPE_THRESHOLD_PX = 64; const SWIPE_THRESHOLD_PX = 64;
const SWIPE_DIRECTION_RATIO = 1.15; const SWIPE_DIRECTION_RATIO = 1.15;
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
interface AppRtcActions { interface AppRtcActions {
connect(): Promise<void>; connect(): Promise<void>;
@ -25,6 +26,36 @@ interface AppRtcActions {
connecting: boolean; connecting: boolean;
} }
function toNullableNumber(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
const raw = window.__nanobotGetCardSelection?.(cardId);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, JsonValue>;
if (record.kind !== "git_diff_range") return null;
if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
const label =
typeof record.label === "string"
? record.label
: `${record.file_label} · ${record.range_label}`;
return {
kind: "git_diff_range",
file_path: filePath,
file_label: record.file_label,
range_label: record.range_label,
label,
old_start: toNullableNumber(record.old_start),
old_end: toNullableNumber(record.old_end),
new_start: toNullableNumber(record.new_start),
new_end: toNullableNumber(record.new_end),
};
}
function buildCardMetadata(card: CardItem): CardMessageMetadata { function buildCardMetadata(card: CardItem): CardMessageMetadata {
const metadata: CardMessageMetadata = { const metadata: CardMessageMetadata = {
card_id: card.serverId, card_id: card.serverId,
@ -35,6 +66,11 @@ function buildCardMetadata(card: CardItem): CardMessageMetadata {
card_context_summary: card.contextSummary, card_context_summary: card.contextSummary,
card_response_value: card.responseValue, card_response_value: card.responseValue,
}; };
const selection = card.serverId ? readCardSelection(card.serverId) : null;
if (selection) {
metadata.card_selection = selection as unknown as JsonValue;
metadata.card_selection_label = selection.label;
}
const liveContent = card.serverId const liveContent = card.serverId
? window.__nanobotGetCardLiveContent?.(card.serverId) ? window.__nanobotGetCardLiveContent?.(card.serverId)
: undefined; : undefined;
@ -42,12 +78,27 @@ function buildCardMetadata(card: CardItem): CardMessageMetadata {
return metadata; return metadata;
} }
function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) { function AgentCardContext({
card,
selection,
onClear,
}: {
card: CardItem;
selection: CardSelectionRange | null;
onClear(): void;
}) {
const label = selection ? "Using diff context" : "Using card";
const title = selection?.file_label || card.title;
const meta = selection?.range_label || "";
return ( return (
<div id="agent-card-context" data-no-swipe="1"> <div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">Using card</div> <div class="agent-card-context-label">{label}</div>
<div class="agent-card-context-row"> <div class="agent-card-context-row">
<div class="agent-card-context-title">{card.title}</div> <div class="agent-card-context-main">
<div class="agent-card-context-title">{title}</div>
{meta && <div class="agent-card-context-meta">{meta}</div>}
</div>
<button <button
class="agent-card-context-clear" class="agent-card-context-clear"
type="button" type="button"
@ -146,6 +197,7 @@ export function App() {
const [view, setView] = useState<"agent" | "feed">("agent"); const [view, setView] = useState<"agent" | "feed">("agent");
const [composing, setComposing] = useState(false); const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null); const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const autoOpenedFeedRef = useRef(false); const autoOpenedFeedRef = useRef(false);
const selectedCard = useMemo( const selectedCard = useMemo(
@ -153,9 +205,13 @@ export function App() {
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null, selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[rtc.cards, selectedCardId], [rtc.cards, selectedCardId],
); );
const selectedCardSelection = useMemo(
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
[selectedCardId, selectionVersion],
);
const selectedCardMetadata = useCallback( const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined), () => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard], [selectedCard, selectionVersion],
); );
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
@ -202,9 +258,29 @@ export function App() {
setSelectedCardId(null); setSelectedCardId(null);
}, [rtc.cards, selectedCardId]); }, [rtc.cards, selectedCardId]);
useEffect(() => {
const handleSelectionChange = (event: Event) => {
const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>)
.detail;
setSelectionVersion((current) => current + 1);
const cardId = typeof detail?.cardId === "string" ? detail.cardId : "";
if (cardId && detail?.selection) setSelectedCardId(cardId);
};
window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
return () => {
window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
};
}, []);
const { handleToggleTextOnly } = useControlActions(rtc); const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId); const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId]);
const handleCardChoice = useCallback( const handleCardChoice = useCallback(
(cardId: string, value: string) => { (cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value }); rtc.sendJson({ type: "card-response", card_id: cardId, value });
@ -220,10 +296,11 @@ export function App() {
const handleResetWithSelection = useCallback(async () => { const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?"); const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return; if (!confirmed) return;
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null); setSelectedCardId(null);
await rtc.connect(); await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" }); rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]); }, [rtc, selectedCardId]);
return ( return (
<> <>
@ -238,7 +315,11 @@ export function App() {
/> />
)} )}
{view === "agent" && selectedCard && ( {view === "agent" && selectedCard && (
<AgentCardContext card={selectedCard} onClear={() => setSelectedCardId(null)} /> <AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={clearSelectedCardContext}
/>
)} )}
{view === "agent" && ( {view === "agent" && (
<LogPanel <LogPanel

View file

@ -8,7 +8,19 @@ const EXECUTABLE_SCRIPT_TYPES = new Set([
"application/javascript", "application/javascript",
"module", "module",
]); ]);
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
const cardLiveContentStore = new Map<string, JsonValue>(); const cardLiveContentStore = new Map<string, JsonValue>();
const cardRefreshHandlers = new Map<string, () => void>();
const cardSelectionStore = new Map<string, JsonValue>();
function cloneJsonValue<T extends JsonValue>(value: T | null | undefined): T | undefined {
if (value === null || value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return undefined;
}
}
function readCardState(script: HTMLScriptElement | null): Record<string, unknown> { function readCardState(script: HTMLScriptElement | null): Record<string, unknown> {
const root = script?.closest("[data-nanobot-card-root]"); const root = script?.closest("[data-nanobot-card-root]");
@ -38,27 +50,79 @@ function setCardLiveContent(
const root = resolveCardRoot(target); const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim(); const cardId = root?.dataset.cardId?.trim();
if (!cardId) return; if (!cardId) return;
if (snapshot === null || snapshot === undefined) { const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
cardLiveContentStore.delete(cardId); cardLiveContentStore.delete(cardId);
return; return;
} }
try { cardLiveContentStore.set(cardId, cloned);
cardLiveContentStore.set(cardId, JSON.parse(JSON.stringify(snapshot)) as JsonValue);
} catch {
cardLiveContentStore.delete(cardId);
}
} }
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined { function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim(); const key = (cardId || "").trim();
if (!key) return undefined; if (!key) return undefined;
const value = cardLiveContentStore.get(key); return cloneJsonValue(cardLiveContentStore.get(key));
if (value === undefined) return undefined; }
try {
return JSON.parse(JSON.stringify(value)) as JsonValue; function setCardRefreshHandler(
} catch { target: HTMLScriptElement | HTMLElement | null,
return undefined; handler: (() => void) | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
if (typeof handler !== "function") {
cardRefreshHandlers.delete(cardId);
return;
} }
cardRefreshHandlers.set(cardId, handler);
}
function runCardRefresh(cardId: string | null | undefined): boolean {
const key = (cardId || "").trim();
if (!key) return false;
const handler = cardRefreshHandlers.get(key);
if (!handler) return false;
handler();
return true;
}
function dispatchCardSelectionChange(cardId: string, selection: JsonValue | undefined): void {
window.dispatchEvent(
new CustomEvent(CARD_SELECTION_EVENT, {
detail: { cardId, selection: selection ?? null },
}),
);
}
function setCardSelection(
target: HTMLScriptElement | HTMLElement | null,
selection: JsonValue | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
const cloned = cloneJsonValue(selection ?? undefined);
if (cloned === undefined) {
cardSelectionStore.delete(cardId);
dispatchCardSelectionChange(cardId, undefined);
return;
}
cardSelectionStore.set(cardId, cloned);
dispatchCardSelectionChange(cardId, cloned);
}
function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(cardSelectionStore.get(key));
}
function clearCardSelection(cardId: string | null | undefined): void {
const key = (cardId || "").trim();
if (!key) return;
cardSelectionStore.delete(key);
dispatchCardSelectionChange(key, undefined);
} }
function ensureCardStateHelper(): void { function ensureCardStateHelper(): void {
@ -71,6 +135,21 @@ function ensureCardStateHelper(): void {
if (!window.__nanobotGetCardLiveContent) { if (!window.__nanobotGetCardLiveContent) {
window.__nanobotGetCardLiveContent = getCardLiveContent; window.__nanobotGetCardLiveContent = getCardLiveContent;
} }
if (!window.__nanobotSetCardRefresh) {
window.__nanobotSetCardRefresh = setCardRefreshHandler;
}
if (!window.__nanobotRefreshCard) {
window.__nanobotRefreshCard = runCardRefresh;
}
if (!window.__nanobotSetCardSelection) {
window.__nanobotSetCardSelection = setCardSelection;
}
if (!window.__nanobotGetCardSelection) {
window.__nanobotGetCardSelection = getCardSelection;
}
if (!window.__nanobotClearCardSelection) {
window.__nanobotClearCardSelection = clearCardSelection;
}
} }
declare global { declare global {
@ -81,6 +160,17 @@ declare global {
snapshot: JsonValue | null | undefined, snapshot: JsonValue | null | undefined,
) => void; ) => void;
__nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined; __nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined;
__nanobotSetCardRefresh?: (
target: HTMLScriptElement | HTMLElement | null,
handler: (() => void) | null | undefined,
) => void;
__nanobotRefreshCard?: (cardId: string | null | undefined) => boolean;
__nanobotSetCardSelection?: (
target: HTMLScriptElement | HTMLElement | null,
selection: JsonValue | null | undefined,
) => void;
__nanobotGetCardSelection?: (cardId: string | null | undefined) => JsonValue | undefined;
__nanobotClearCardSelection?: (cardId: string | null | undefined) => void;
} }
} }
@ -128,6 +218,8 @@ function CardTextBody({ card }: { card: CardItem }) {
} }
return () => { return () => {
window.__nanobotSetCardLiveContent?.(bodyRef.current, null); window.__nanobotSetCardLiveContent?.(bodyRef.current, null);
window.__nanobotSetCardRefresh?.(bodyRef.current, null);
window.__nanobotSetCardSelection?.(bodyRef.current, null);
}; };
}, [card.id, card.content]); }, [card.id, card.content]);
@ -184,6 +276,7 @@ function CardHeader({
}) { }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const canRefresh = card.kind === "text" && card.templateKey === "git-diff-live";
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@ -235,6 +328,21 @@ function CardHeader({
</button> </button>
{menuOpen && ( {menuOpen && (
<div class="card-menu" role="menu"> <div class="card-menu" role="menu">
{canRefresh && (
<button
class="card-menu-item"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
if (!card.serverId) return;
window.__nanobotRefreshCard?.(card.serverId);
}}
>
Refresh
</button>
)}
{card.kind === "text" && ( {card.kind === "text" && (
<button <button
class="card-menu-item" class="card-menu-item"

View file

@ -294,11 +294,16 @@ function useCardPolling(loadPersistedCards: () => Promise<void>) {
const onVisible = () => { const onVisible = () => {
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {}); if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
}; };
const onCardsRefresh = () => {
loadPersistedCards().catch(() => {});
};
window.addEventListener("focus", onVisible); window.addEventListener("focus", onVisible);
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
document.addEventListener("visibilitychange", onVisible); document.addEventListener("visibilitychange", onVisible);
return () => { return () => {
window.clearInterval(pollId); window.clearInterval(pollId);
window.removeEventListener("focus", onVisible); window.removeEventListener("focus", onVisible);
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("visibilitychange", onVisible);
}; };
}, [loadPersistedCards]); }, [loadPersistedCards]);

View file

@ -4,6 +4,10 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
:root {
--card-font: "Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
}
html { html {
font: -apple-system-body; font: -apple-system-body;
} }
@ -29,7 +33,7 @@ body {
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
touch-action: pan-y; touch-action: manipulation;
} }
#swipe-track { #swipe-track {
@ -401,14 +405,17 @@ body {
} }
.agent-card-context-row { .agent-card-context-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin-top: 4px; margin-top: 4px;
} }
.agent-card-context-title { .agent-card-context-main {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
}
.agent-card-context-title {
min-width: 0;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.2; line-height: 1.2;
font-weight: 700; font-weight: 700;
@ -417,6 +424,16 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.agent-card-context-meta {
margin-top: 2px;
font-size: 0.68rem;
line-height: 1.2;
font-weight: 600;
color: #8b654b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-context-clear { .agent-card-context-clear {
flex-shrink: 0; flex-shrink: 0;
border: 1px solid #e3d6ca; border: 1px solid #e3d6ca;
@ -617,7 +634,7 @@ body {
padding: 0; padding: 0;
z-index: 2; z-index: 2;
pointer-events: auto; pointer-events: auto;
touch-action: pan-y; touch-action: manipulation;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255, 200, 140, 0.25) transparent; scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
@ -848,7 +865,7 @@ body {
.card-question, .card-question,
.card-response, .card-response,
.card-footer { .card-footer {
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-family: var(--card-font);
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.65; line-height: 1.65;
color: rgba(255, 245, 235, 0.82); color: rgba(255, 245, 235, 0.82);
@ -968,7 +985,7 @@ body {
border: 1px solid rgba(255, 200, 140, 0.35); border: 1px solid rgba(255, 200, 140, 0.35);
border-radius: 8px; border-radius: 8px;
color: rgba(255, 245, 235, 0.9); color: rgba(255, 245, 235, 0.9);
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-family: var(--card-font);
font-size: 0.75rem; font-size: 0.75rem;
padding: 6px 14px; padding: 6px 14px;
cursor: pointer; cursor: pointer;

View file

@ -1,5 +1,9 @@
import { render } from "preact"; import { render } from "preact";
import { App } from "./App"; import { App } from "./App";
import "@fontsource/iosevka/400.css";
import "@fontsource/iosevka/600.css";
import "@fontsource/iosevka/700.css";
import "@fontsource/iosevka/800.css";
import "./index.css"; import "./index.css";
const root = document.getElementById("app"); const root = document.getElementById("app");

View file

@ -11,6 +11,18 @@ export type JsonValue =
| { [key: string]: JsonValue } | { [key: string]: JsonValue }
| JsonValue[]; | JsonValue[];
export interface CardSelectionRange {
kind: "git_diff_range";
file_path: string;
file_label: string;
range_label: string;
label: string;
old_start: number | null;
old_end: number | null;
new_start: number | null;
new_end: number | null;
}
export interface CardMessageMetadata { export interface CardMessageMetadata {
card_id?: string; card_id?: string;
card_slot?: string; card_slot?: string;
@ -19,6 +31,8 @@ export interface CardMessageMetadata {
card_template_key?: string; card_template_key?: string;
card_context_summary?: string; card_context_summary?: string;
card_response_value?: string; card_response_value?: string;
card_selection_label?: string;
card_selection?: JsonValue;
card_live_content?: JsonValue; card_live_content?: JsonValue;
} }

View file

@ -62,6 +62,7 @@ class NanobotApiProcess:
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
self._read_task: asyncio.Task | None = None self._read_task: asyncio.Task | None = None
self._socket_inode: int | None = None
@property @property
def running(self) -> bool: def running(self) -> bool:
@ -72,6 +73,16 @@ class NanobotApiProcess:
and not self._read_task.done() and not self._read_task.done()
) )
def matches_current_socket(self) -> bool:
if self._socket_inode is None:
return False
try:
return self._socket_path.stat().st_ino == self._socket_inode
except FileNotFoundError:
return False
except OSError:
return False
async def start(self) -> None: async def start(self) -> None:
if self.running: if self.running:
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
@ -95,6 +106,7 @@ class NanobotApiProcess:
self._reader, self._writer = await asyncio.open_unix_connection( self._reader, self._writer = await asyncio.open_unix_connection(
path=str(self._socket_path) path=str(self._socket_path)
) )
self._socket_inode = self._socket_path.stat().st_ino
except OSError as exc: except OSError as exc:
await self._bus.publish( await self._bus.publish(
WisperEvent(role="system", text=f"Could not connect to nanobot API socket: {exc}") WisperEvent(role="system", text=f"Could not connect to nanobot API socket: {exc}")
@ -107,7 +119,7 @@ class NanobotApiProcess:
async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None: async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None:
if not self.running or self._writer is None: if not self.running or self._writer is None:
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
return raise RuntimeError("Not connected to nanobot.")
try: try:
await self._send_notification( await self._send_notification(
"message.send", "message.send",
@ -120,10 +132,11 @@ class NanobotApiProcess:
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()
raise RuntimeError(f"Send failed: {exc}") from exc
async def send_card_response(self, card_id: str, value: str) -> None: async def send_card_response(self, card_id: str, value: str) -> None:
if not self.running or self._writer is None: if not self.running or self._writer is None:
return raise RuntimeError("Not connected to nanobot.")
try: try:
await self._send_notification( await self._send_notification(
"card.respond", "card.respond",
@ -135,11 +148,12 @@ class NanobotApiProcess:
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()
raise RuntimeError(f"Send failed: {exc}") from exc
async def send_command(self, command: str) -> None: async def send_command(self, command: str) -> None:
if not self.running or self._writer is None: if not self.running or self._writer is None:
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
return raise RuntimeError("Not connected to nanobot.")
try: try:
await self._send_notification( await self._send_notification(
"command.execute", "command.execute",
@ -151,6 +165,7 @@ class NanobotApiProcess:
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()
raise RuntimeError(f"Send failed: {exc}") from exc
async def stop(self) -> None: async def stop(self) -> None:
await self._cleanup() await self._cleanup()
@ -173,6 +188,7 @@ class NanobotApiProcess:
pass pass
self._writer = None self._writer = None
self._reader = None self._reader = None
self._socket_inode = None
async def _send_notification(self, method: str, params: dict[str, Any]) -> None: async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
assert self._writer is not None assert self._writer is not None
@ -264,26 +280,35 @@ class SuperTonicGateway:
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 _ensure_connected_process(self) -> NanobotApiProcess:
if self._process and self._process.running and self._process.matches_current_socket():
return self._process
if self._process:
await self._process.stop()
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
await self._process.start()
if not self._process.running or not self._process.matches_current_socket():
raise RuntimeError("Not connected to nanobot.")
return self._process
async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> None: async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> 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: process = await self._ensure_connected_process()
await self.bus.publish(WisperEvent(role="system", text="Not connected to nanobot.")) await process.send(message, metadata=metadata)
return
await self._process.send(message, metadata=metadata)
async def send_card_response(self, card_id: str, value: str) -> None: async def send_card_response(self, card_id: str, value: str) -> None:
async with self._lock: async with self._lock:
if self._process: process = await self._ensure_connected_process()
await self._process.send_card_response(card_id, value) await process.send_card_response(card_id, value)
async def send_command(self, command: str) -> None: async def send_command(self, command: str) -> None:
async with self._lock: async with self._lock:
if self._process: process = await self._ensure_connected_process()
await self._process.send_command(command) await process.send_command(command)
async def disconnect_nanobot(self) -> None: async def disconnect_nanobot(self) -> None:
async with self._lock: async with self._lock:

View file

@ -1045,7 +1045,7 @@ class WebRTCVoiceSession:
if self._stt_worker_task: if self._stt_worker_task:
self._stt_worker_task.cancel() self._stt_worker_task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError, RuntimeError):
await self._stt_worker_task await self._stt_worker_task
self._stt_worker_task = None self._stt_worker_task = None
@ -1313,10 +1313,15 @@ 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}")
) )
try:
await self._gateway.send_user_message( await self._gateway.send_user_message(
transcript, transcript,
metadata=dict(self._active_message_metadata), metadata=dict(self._active_message_metadata),
) )
except RuntimeError as exc:
if self._closed:
return
await self._publish_system(f"Could not deliver voice transcript: {exc}")
async def _close_peer_connection(self) -> None: async def _close_peer_connection(self) -> None:
self._dc = None self._dc = None