chore: snapshot current state before cleanup
This commit is contained in:
parent
db4ce8b14f
commit
94e62c9456
14 changed files with 489 additions and 3929 deletions
197
app.py
197
app.py
|
|
@ -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"})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=="],
|
||||||
|
|
|
||||||
3869
frontend/dist/assets/index-D7b7A0h0.js
vendored
3869
frontend/dist/assets/index-D7b7A0h0.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-IlzmBIpb.css
vendored
1
frontend/dist/assets/index-IlzmBIpb.css
vendored
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCardRefreshHandler(
|
||||||
|
target: HTMLScriptElement | HTMLElement | null,
|
||||||
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue