chore: clean up web ui repo hygiene
This commit is contained in:
parent
94e62c9456
commit
2fcc9db903
4786 changed files with 1271 additions and 1275231 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue