chore: clean up web ui repo hygiene

This commit is contained in:
kacper 2026-03-14 20:21:44 -04:00
parent 94e62c9456
commit 2fcc9db903
4786 changed files with 1271 additions and 1275231 deletions

View file

@ -6,11 +6,19 @@ import { LogPanel } from "./components/LogPanel";
import { useAudioMeter } from "./hooks/useAudioMeter";
import { usePTT } from "./hooks/usePTT";
import { useWebRTC } from "./hooks/useWebRTC";
import type { CardItem, CardMessageMetadata, CardSelectionRange, JsonValue } from "./types";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardSelectionRange,
JsonValue,
LogLine,
} from "./types";
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_DIRECTION_RATIO = 1.15;
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
type WorkspaceView = "agent" | "feed";
interface AppRtcActions {
connect(): Promise<void>;
@ -118,8 +126,8 @@ function AgentCardContext({
function useSwipeHandlers(
composing: boolean,
view: "agent" | "feed",
setView: (view: "agent" | "feed") => void,
view: WorkspaceView,
setView: (view: WorkspaceView) => void,
isInteractiveTarget: (target: EventTarget | null) => boolean,
) {
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
@ -155,7 +163,7 @@ function useSwipeHandlers(
}
function useCardActions(
setView: (view: "agent" | "feed") => void,
setView: (view: WorkspaceView) => void,
setSelectedCardId: (cardId: string | null) => void,
) {
const handleAskCard = useCallback(
@ -170,6 +178,29 @@ function useCardActions(
return { handleAskCard };
}
function useGlobalPointerBindings({
handlePointerDown,
handlePointerMove,
handlePointerUp,
}: {
handlePointerDown: (event: Event) => void;
handlePointerMove: (event: Event) => void;
handlePointerUp: (event: Event) => void;
}) {
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointermove", handlePointerMove, { passive: true });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
}
function useControlActions(rtc: AppRtcActions) {
const handleReset = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
@ -189,21 +220,63 @@ function useControlActions(rtc: AppRtcActions) {
return { handleReset, handleToggleTextOnly };
}
export function App() {
const rtc = useWebRTC();
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
function useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
}: {
rtc: AppRtcActions & {
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
};
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
selectedCardMetadata: () => CardMessageMetadata | undefined;
}) {
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId, setSelectedCardId]);
const [view, setView] = useState<"agent" | "feed">("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const autoOpenedFeedRef = useRef(false);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
},
[rtc],
);
const handleSendMessage = useCallback(
async (text: string) => {
await rtc.sendTextMessage(text, selectedCardMetadata());
},
[rtc, selectedCardMetadata],
);
const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
clearSelectedCardContext();
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [clearSelectedCardContext, rtc]);
return {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
handleResetWithSelection,
};
}
function useSelectedCardContext(
cards: CardItem[],
selectedCardId: string | null,
selectionVersion: number,
) {
const selectedCard = useMemo(
() =>
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[rtc.cards, selectedCardId],
selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[cards, selectedCardId],
);
const selectedCardSelection = useMemo(
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
@ -214,6 +287,155 @@ export function App() {
[selectedCard, selectionVersion],
);
return { selectedCard, selectedCardSelection, selectedCardMetadata };
}
function useCardSelectionLifecycle({
cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
setView,
}: {
cards: CardItem[];
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
setSelectionVersion: (updater: (current: number) => number) => void;
setView: (view: WorkspaceView) => void;
}) {
const autoOpenedFeedRef = useRef(false);
useEffect(() => {
if (autoOpenedFeedRef.current || cards.length === 0) return;
autoOpenedFeedRef.current = true;
setView("feed");
}, [cards.length, setView]);
useEffect(() => {
if (!selectedCardId) return;
if (cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [cards, selectedCardId, setSelectedCardId]);
useEffect(() => {
const handleSelectionChange = (event: Event) => {
const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>)
.detail;
setSelectionVersion((current) => current + 1);
const cardId = typeof detail?.cardId === "string" ? detail.cardId : "";
if (cardId && detail?.selection) setSelectedCardId(cardId);
};
window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
return () => {
window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
};
}, [setSelectedCardId, setSelectionVersion]);
}
function AgentWorkspace({
active,
selectedCard,
selectedCardSelection,
onClearSelectedCardContext,
onReset,
textOnly,
onToggleTextOnly,
logLines,
connected,
onSendMessage,
onExpandChange,
effectiveAgentState,
connecting,
audioLevel,
}: {
active: boolean;
selectedCard: CardItem | null;
selectedCardSelection: CardSelectionRange | null;
onClearSelectedCardContext(): void;
onReset(): Promise<void>;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): Promise<void>;
logLines: LogLine[];
connected: boolean;
onSendMessage(text: string): Promise<void>;
onExpandChange(expanded: boolean): void;
effectiveAgentState: AgentState;
connecting: boolean;
audioLevel: number;
}) {
return (
<section class="workspace-panel workspace-agent">
{active && (
<ControlBar onReset={onReset} textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />
)}
{active && selectedCard && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={onClearSelectedCardContext}
/>
)}
{active && (
<LogPanel
lines={logLines}
disabled={!connected}
onSendMessage={onSendMessage}
onExpandChange={onExpandChange}
/>
)}
<AgentIndicator
state={effectiveAgentState}
connected={connected}
connecting={connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
</section>
);
}
function FeedWorkspace({
cards,
onDismiss,
onChoice,
onAskCard,
}: {
cards: CardItem[];
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}) {
return (
<section class="workspace-panel workspace-feed">
<CardFeed
cards={cards}
viewActive
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
</section>
);
}
export function App() {
const rtc = useWebRTC();
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<WorkspaceView>("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
rtc.cards,
selectedCardId,
selectionVersion,
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly,
onSendPtt: (pressed) =>
@ -232,122 +454,55 @@ export function App() {
setView,
isInteractiveTarget,
);
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointermove", handlePointerMove, { passive: true });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
useEffect(() => {
if (autoOpenedFeedRef.current || rtc.cards.length === 0) return;
autoOpenedFeedRef.current = true;
setView("feed");
}, [rtc.cards.length]);
useEffect(() => {
if (!selectedCardId) return;
if (rtc.cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [rtc.cards, selectedCardId]);
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);
};
}, []);
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
useCardSelectionLifecycle({
cards: rtc.cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
setView,
});
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId]);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
},
[rtc],
);
const handleSendMessage = useCallback(
async (text: string) => {
await rtc.sendTextMessage(text, selectedCardMetadata());
},
[rtc, selectedCardMetadata],
);
const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc, selectedCardId]);
const {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
handleResetWithSelection,
} = useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
});
return (
<>
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
<section class="workspace-panel workspace-agent">
{view === "agent" && (
<ControlBar
onReset={handleResetWithSelection}
textOnly={rtc.textOnly}
onToggleTextOnly={handleToggleTextOnly}
/>
)}
{view === "agent" && selectedCard && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={clearSelectedCardContext}
/>
)}
{view === "agent" && (
<LogPanel
lines={rtc.logLines}
disabled={!rtc.connected}
onSendMessage={handleSendMessage}
onExpandChange={setComposing}
/>
)}
<AgentIndicator
state={effectiveAgentState}
connected={rtc.connected}
connecting={rtc.connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
</section>
<section class="workspace-panel workspace-feed">
<CardFeed
cards={rtc.cards}
viewActive
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</section>
<AgentWorkspace
active={view === "agent"}
selectedCard={selectedCard}
selectedCardSelection={selectedCardSelection}
onClearSelectedCardContext={clearSelectedCardContext}
onReset={handleResetWithSelection}
textOnly={rtc.textOnly}
onToggleTextOnly={handleToggleTextOnly}
logLines={rtc.logLines}
connected={rtc.connected}
onSendMessage={handleSendMessage}
onExpandChange={setComposing}
effectiveAgentState={effectiveAgentState}
connecting={rtc.connecting}
audioLevel={audioLevel}
/>
<FeedWorkspace
cards={rtc.cards}
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</div>
</div>
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />

View file

@ -1,5 +1,5 @@
import { useEffect, useRef } from "preact/hooks";
import { type AgentVisualizerHandle, createAgentVisualizer } from "../AgentVisualizer";
import type { AgentVisualizerHandle } from "../AgentVisualizer";
import type { AgentState } from "../types";
interface Props {
@ -23,14 +23,35 @@ export function AgentIndicator({
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const vizRef = useRef<AgentVisualizerHandle | null>(null);
const latestStateRef = useRef({ state, connected, connecting, audioLevel });
latestStateRef.current = { state, connected, connecting, audioLevel };
useEffect(() => {
if (!containerRef.current) return;
const viz = createAgentVisualizer(containerRef.current);
vizRef.current = viz;
let cancelled = false;
let localViz: AgentVisualizerHandle | null = null;
const loadVisualizer = async () => {
if (!containerRef.current) return;
const { createAgentVisualizer } = await import("../AgentVisualizer");
if (cancelled || !containerRef.current) return;
localViz = createAgentVisualizer(containerRef.current);
vizRef.current = localViz;
const latest = latestStateRef.current;
localViz.setState(latest.state);
localViz.setConnected(latest.connected);
localViz.setConnecting(latest.connecting);
localViz.setAudioLevel(latest.audioLevel);
};
loadVisualizer().catch(() => {});
return () => {
viz.destroy();
vizRef.current = null;
cancelled = true;
localViz?.destroy();
if (vizRef.current === localViz) vizRef.current = null;
};
}, []);

View file

@ -13,6 +13,37 @@ const cardLiveContentStore = new Map<string, JsonValue>();
const cardRefreshHandlers = new Map<string, () => void>();
const cardSelectionStore = new Map<string, JsonValue>();
interface ManualToolResult {
tool_name: string;
content: string;
parsed: JsonValue | null;
is_json: boolean;
}
interface ManualToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
kind?: string;
}
interface ManualToolJob {
job_id: string;
tool_name: string;
status: "queued" | "running" | "completed" | "failed";
created_at: string;
started_at: string | null;
finished_at: string | null;
result: ManualToolResult | null;
error: string | null;
error_code: number | null;
}
interface ManualToolAsyncOptions {
pollMs?: number;
timeoutMs?: number;
}
function cloneJsonValue<T extends JsonValue>(value: T | null | undefined): T | undefined {
if (value === null || value === undefined) return undefined;
try {
@ -125,6 +156,201 @@ function clearCardSelection(cardId: string | null | undefined): void {
dispatchCardSelectionChange(key, undefined);
}
async function decodeJsonError(resp: Response): Promise<string> {
try {
const payload = (await resp.json()) as { error?: unknown };
if (payload && typeof payload === "object" && typeof payload.error === "string") {
return payload.error;
}
} catch {
// Ignore invalid error bodies and fall back to the status code.
}
return `request failed (${resp.status})`;
}
function normalizeManualToolResult(
payload: Partial<ManualToolResult> | null | undefined,
fallbackName: string,
): ManualToolResult {
return {
tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName,
content: typeof payload?.content === "string" ? payload.content : "",
parsed:
payload?.parsed === null || payload?.parsed === undefined
? null
: (cloneJsonValue(payload.parsed as JsonValue) ?? null),
is_json: payload?.is_json === true,
};
}
function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob {
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName;
const statusValue = typeof record.status === "string" ? record.status : "queued";
return {
job_id: typeof record.job_id === "string" ? record.job_id : "",
tool_name: toolName,
status:
statusValue === "running" || statusValue === "completed" || statusValue === "failed"
? statusValue
: "queued",
created_at: typeof record.created_at === "string" ? record.created_at : "",
started_at: typeof record.started_at === "string" ? record.started_at : null,
finished_at: typeof record.finished_at === "string" ? record.finished_at : null,
result:
record.result && typeof record.result === "object"
? normalizeManualToolResult(record.result as Partial<ManualToolResult>, toolName)
: null,
error: typeof record.error === "string" ? record.error : null,
error_code: typeof record.error_code === "number" ? record.error_code : null,
};
}
function normalizeManualToolAsyncOptions(options: ManualToolAsyncOptions): {
pollMs: number;
timeoutMs: number;
} {
const pollMs =
typeof options.pollMs === "number" && Number.isFinite(options.pollMs) && options.pollMs >= 100
? options.pollMs
: 400;
const timeoutMs =
typeof options.timeoutMs === "number" &&
Number.isFinite(options.timeoutMs) &&
options.timeoutMs >= 1000
? options.timeoutMs
: 120000;
return { pollMs, timeoutMs };
}
async function waitForManualToolJob(
initialJob: ManualToolJob,
toolName: string,
timeoutMs: number,
pollMs: number,
): Promise<ManualToolResult> {
const deadline = Date.now() + timeoutMs;
let job = initialJob;
while (true) {
if (job.status === "completed") {
if (!job.result) throw new Error("tool job completed without a result");
return job.result;
}
if (job.status === "failed") {
throw new Error(job.error || `tool job failed (${job.tool_name || toolName})`);
}
if (Date.now() >= deadline) {
throw new Error(`tool job timed out after ${Math.round(timeoutMs / 1000)}s`);
}
await new Promise((resolve) => window.setTimeout(resolve, pollMs));
job = await getManualToolJob(job.job_id);
}
}
async function callManualTool(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolResult> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool response");
}
return normalizeManualToolResult(payload as Partial<ManualToolResult>, name);
}
async function startManualToolCall(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolJob> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
const job = normalizeManualToolJob(payload, name);
if (!job.job_id) throw new Error("tool job id is required");
return job;
}
async function getManualToolJob(jobId: string): Promise<ManualToolJob> {
const key = jobId.trim();
if (!key) throw new Error("tool job id is required");
const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
return normalizeManualToolJob(payload, "");
}
async function callManualToolAsync(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
options: ManualToolAsyncOptions = {},
): Promise<ManualToolResult> {
const { pollMs, timeoutMs } = normalizeManualToolAsyncOptions(options);
const job = await startManualToolCall(toolName, argumentsValue);
return waitForManualToolJob(job, toolName, timeoutMs, pollMs);
}
async function listManualTools(): Promise<ManualToolDefinition[]> {
const resp = await fetch("/tools", { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { tools?: unknown };
const tools = Array.isArray(payload?.tools) ? payload.tools : [];
return tools
.filter((tool): tool is Record<string, unknown> => !!tool && typeof tool === "object")
.map((tool) => ({
name: typeof tool.name === "string" ? tool.name : "",
description: typeof tool.description === "string" ? tool.description : "",
parameters:
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? (tool.parameters as Record<string, unknown>)
: {},
kind: typeof tool.kind === "string" ? tool.kind : undefined,
}))
.filter((tool) => tool.name);
}
function ensureCardStateHelper(): void {
if (!window.__nanobotGetCardState) {
window.__nanobotGetCardState = readCardState;
@ -150,6 +376,21 @@ function ensureCardStateHelper(): void {
if (!window.__nanobotClearCardSelection) {
window.__nanobotClearCardSelection = clearCardSelection;
}
if (!window.__nanobotCallTool) {
window.__nanobotCallTool = callManualTool;
}
if (!window.__nanobotStartToolCall) {
window.__nanobotStartToolCall = startManualToolCall;
}
if (!window.__nanobotGetToolJob) {
window.__nanobotGetToolJob = getManualToolJob;
}
if (!window.__nanobotCallToolAsync) {
window.__nanobotCallToolAsync = callManualToolAsync;
}
if (!window.__nanobotListTools) {
window.__nanobotListTools = listManualTools;
}
}
declare global {
@ -171,6 +412,21 @@ declare global {
) => void;
__nanobotGetCardSelection?: (cardId: string | null | undefined) => JsonValue | undefined;
__nanobotClearCardSelection?: (cardId: string | null | undefined) => void;
__nanobotCallTool?: (
toolName: string,
argumentsValue?: Record<string, JsonValue>,
) => Promise<ManualToolResult>;
__nanobotStartToolCall?: (
toolName: string,
argumentsValue?: Record<string, JsonValue>,
) => Promise<ManualToolJob>;
__nanobotGetToolJob?: (jobId: string) => Promise<ManualToolJob>;
__nanobotCallToolAsync?: (
toolName: string,
argumentsValue?: Record<string, JsonValue>,
options?: ManualToolAsyncOptions,
) => Promise<ManualToolResult>;
__nanobotListTools?: () => Promise<ManualToolDefinition[]>;
}
}

View file

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

View file

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

View file

@ -51,6 +51,9 @@ interface WebRTCState {
type AppendLine = (role: string, text: string, timestamp: string) => void;
type UpsertCard = (item: Omit<CardItem, "id">) => void;
type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
type RawPersistedCard =
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
@ -61,6 +64,7 @@ interface RTCRefs {
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
localTracksRef: { current: MediaStreamTrack[] };
}
interface RTCCallbacks {
@ -89,6 +93,15 @@ function sortCards(items: CardItem[]): CardItem[] {
return [...items].sort(compareCards);
}
function stopMediaTracks(tracks: Iterable<MediaStreamTrack | null | undefined>): void {
const seen = new Set<MediaStreamTrack>();
for (const track of tracks) {
if (!track || seen.has(track)) continue;
seen.add(track);
track.stop();
}
}
function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
@ -109,6 +122,55 @@ function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardIte
};
}
function appendLogLineEntry(
prev: LogLine[],
role: string,
text: string,
timestamp: string,
): LogLine[] {
const next = [
...prev,
{ id: logIdCounter++, role, text, timestamp: timestamp || new Date().toISOString() },
];
return next.length > 250 ? next.slice(next.length - 250) : next;
}
function mergeCardItem(prev: CardItem[], item: Omit<CardItem, "id">): CardItem[] {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
}
function normalizePersistedCard(raw: RawPersistedCard): Omit<CardItem, "id"> {
return toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
}
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = normalizePersistedCard(raw);
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
setAgentState: SetAgentState,
@ -205,11 +267,14 @@ async function runConnect(
let micStream: MediaStream | null = null;
try {
refs.localTracksRef.current = [];
if (!opts.textOnly) {
micStream = await acquireMicStream();
micStream.getAudioTracks().forEach((track) => {
const audioTracks = micStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = false;
});
refs.localTracksRef.current = audioTracks;
}
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
@ -263,9 +328,6 @@ async function runConnect(
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
micStream?.getTracks().forEach((track) => {
track.stop();
});
}
}
@ -371,34 +433,58 @@ function useRemoteAudioBindings({
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
function useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle");
const [logLines, setLogLines] = useState<LogLine[]>([]);
const [cards, setCards] = useState<CardItem[]>([]);
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => {
const next = [
...prev,
{ id: logIdCounter++, role, text, timestamp: timestamp || new Date().toISOString() },
];
return next.length > 250 ? next.slice(next.length - 250) : next;
});
const clear = useCallback(() => {
if (!idleTimerRef.current) return;
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}, []);
const schedule = useCallback(
(delayMs = 450) => {
clear();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clear, setAgentState],
);
useEffect(() => clear, [clear]);
return { clear, schedule };
}
function useLogState() {
const [logLines, setLogLines] = useState<LogLine[]>([]);
const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp));
}, []);
return { logLines, appendLine };
}
async function fetchPersistedCardsFromBackend(): Promise<RawPersistedCard[] | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/cards` : "/cards";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return null;
}
return (await resp.json()) as RawPersistedCard[];
}
function useCardsState() {
const [cards, setCards] = useState<CardItem[]>([]);
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
});
setCards((prev) => mergeCardItem(prev, item));
}, []);
const dismissCard = useCallback((id: number) => {
@ -414,84 +500,62 @@ function useMessageState() {
});
}, []);
const clearIdleFallback = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
}, []);
const scheduleIdleFallback = useCallback(
(delayMs = 450) => {
clearIdleFallback();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clearIdleFallback],
);
useEffect(() => clearIdleFallback, [clearIdleFallback]);
const loadPersistedCards = useCallback(async () => {
try {
const url = BACKEND_URL ? `${BACKEND_URL}/cards` : "/cards";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return;
}
const rawCards = (await resp.json()) as Array<
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" })
>;
setCards((prev) => {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
});
const rawCards = await fetchPersistedCardsFromBackend();
if (!rawCards) return;
setCards((prev) => reconcilePersistedCards(prev, rawCards));
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, []);
const onDcMessage = useCallback(
return { cards, upsertCard, dismissCard, loadPersistedCards };
}
function parseServerMessage(raw: string): Extract<ServerMessage, { type: string }> | null {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return null;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return null;
return msg as Extract<ServerMessage, { type: string }>;
}
function useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
}: {
setAgentState: SetAgentState;
appendLine: AppendLine;
upsertCard: UpsertCard;
idleFallback: IdleFallbackControls;
}) {
return useCallback(
(raw: string) => {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
handleTypedMessage(
msg as Extract<ServerMessage, { type: string }>,
setAgentState,
appendLine,
upsertCard,
{ clear: clearIdleFallback, schedule: scheduleIdleFallback },
);
const msg = parseServerMessage(raw);
if (!msg) return;
handleTypedMessage(msg, setAgentState, appendLine, upsertCard, idleFallback);
},
[appendLine, clearIdleFallback, scheduleIdleFallback, upsertCard],
[appendLine, idleFallback, setAgentState, upsertCard],
);
}
function useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle");
const { logLines, appendLine } = useLogState();
const { cards, upsertCard, dismissCard, loadPersistedCards } = useCardsState();
const idleFallback = useIdleFallback(setAgentState);
const onDcMessage = useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
});
return { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage };
}
@ -522,16 +586,30 @@ function usePeerConnectionControls({
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
refs.dcRef.current?.close();
const dc = refs.dcRef.current;
const pc = refs.pcRef.current;
const localTracks = refs.localTracksRef.current;
const senderTracks = refs.micSendersRef.current.map((sender) => sender.track);
refs.dcRef.current = null;
refs.pcRef.current?.close();
refs.pcRef.current = null;
refs.micSendersRef.current = [];
refs.localTracksRef.current = [];
stopMediaTracks([...senderTracks, ...localTracks]);
dc?.close();
pc?.close();
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const closePCRef = useRef(closePC);
useEffect(() => {
closePCRef.current = closePC;
}, [closePC]);
const connect = useCallback(async () => {
await runConnect(
@ -569,6 +647,12 @@ function usePeerConnectionControls({
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
useEffect(() => {
return () => {
closePCRef.current();
};
}, []);
return { closePC, connect };
}
@ -584,6 +668,7 @@ export function useWebRTC(): WebRTCState {
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
localTracksRef: useRef<MediaStreamTrack[]>([]),
};
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textOnlyRef = useRef(false);

View file

@ -1006,63 +1006,3 @@ body {
opacity: 0.4;
cursor: default;
}
/* --- FAB (floating action button) --- */
#fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: rgba(28, 22, 16, 0.88);
color: rgba(255, 245, 235, 0.85);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.45),
0 1px 4px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
touch-action: none;
transition:
background 0.15s,
transform 0.1s,
box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
outline: none;
}
#fab:hover {
background: rgba(40, 32, 22, 0.95);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.5),
0 2px 6px rgba(0, 0, 0, 0.3);
}
#fab:active,
#fab.ptt-active {
transform: scale(0.93);
background: rgba(180, 100, 50, 0.9);
color: #fff;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.4),
0 0 0 3px rgba(255, 190, 120, 0.35);
}
#fab-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: rgba(220, 80, 50, 0.95);
color: #fff;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 0.625rem;
font-weight: 700;
line-height: 18px;
text-align: center;
pointer-events: none;
}

View file

@ -1,9 +1,5 @@
import { render } from "preact";
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";
const root = document.getElementById("app");