feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
|
|
@ -1,512 +1,159 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { AgentIndicator } from "./components/AgentIndicator";
|
||||
import { CardFeed } from "./components/CardFeed";
|
||||
import { ControlBar, VoiceStatus } from "./components/Controls";
|
||||
import { LogPanel } from "./components/LogPanel";
|
||||
import { useAudioMeter } from "./hooks/useAudioMeter";
|
||||
import { usePTT } from "./hooks/usePTT";
|
||||
import { useState } from "preact/hooks";
|
||||
import {
|
||||
AgentWorkspace,
|
||||
FeedWorkspace,
|
||||
SwipeWorkspace,
|
||||
useAppPresentation,
|
||||
useSessionDrawerEdgeSwipe,
|
||||
} from "./appShell/presentation";
|
||||
import { VoiceStatus } from "./components/Controls";
|
||||
import { SessionDrawer } from "./components/SessionDrawer";
|
||||
import { WorkbenchOverlay } from "./components/WorkbenchOverlay";
|
||||
import { useWebRTC } from "./hooks/useWebRTC";
|
||||
import type {
|
||||
AgentState,
|
||||
CardItem,
|
||||
CardMessageMetadata,
|
||||
CardSelectionRange,
|
||||
JsonValue,
|
||||
LogLine,
|
||||
} from "./types";
|
||||
import type { ThemeName, ThemeOption } from "./theme/themes";
|
||||
import { useThemePreference } from "./theme/useThemePreference";
|
||||
|
||||
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>;
|
||||
sendJson(
|
||||
msg:
|
||||
| { type: "command"; command: string }
|
||||
| { type: "card-response"; card_id: string; value: string }
|
||||
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
|
||||
): void;
|
||||
setTextOnly(enabled: boolean): void;
|
||||
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: JsonValue | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
|
||||
const raw = window.__nanobotGetCardSelection?.(cardId);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, JsonValue>;
|
||||
if (record.kind !== "git_diff_range") return null;
|
||||
if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
|
||||
|
||||
const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
|
||||
const label =
|
||||
typeof record.label === "string"
|
||||
? record.label
|
||||
: `${record.file_label} · ${record.range_label}`;
|
||||
|
||||
return {
|
||||
kind: "git_diff_range",
|
||||
file_path: filePath,
|
||||
file_label: record.file_label,
|
||||
range_label: record.range_label,
|
||||
label,
|
||||
old_start: toNullableNumber(record.old_start),
|
||||
old_end: toNullableNumber(record.old_end),
|
||||
new_start: toNullableNumber(record.new_start),
|
||||
new_end: toNullableNumber(record.new_end),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCardMetadata(card: CardItem): CardMessageMetadata {
|
||||
const metadata: CardMessageMetadata = {
|
||||
card_id: card.serverId,
|
||||
card_slot: card.slot,
|
||||
card_title: card.title,
|
||||
card_lane: card.lane,
|
||||
card_template_key: card.templateKey,
|
||||
card_context_summary: card.contextSummary,
|
||||
card_response_value: card.responseValue,
|
||||
};
|
||||
const selection = card.serverId ? readCardSelection(card.serverId) : null;
|
||||
if (selection) {
|
||||
metadata.card_selection = selection as unknown as JsonValue;
|
||||
metadata.card_selection_label = selection.label;
|
||||
}
|
||||
const liveContent = card.serverId
|
||||
? window.__nanobotGetCardLiveContent?.(card.serverId)
|
||||
: undefined;
|
||||
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function AgentCardContext({
|
||||
card,
|
||||
selection,
|
||||
onClear,
|
||||
}: {
|
||||
card: CardItem;
|
||||
selection: CardSelectionRange | null;
|
||||
onClear(): void;
|
||||
}) {
|
||||
const label = selection ? "Using diff context" : "Using card";
|
||||
const title = selection?.file_label || card.title;
|
||||
const meta = selection?.range_label || "";
|
||||
|
||||
return (
|
||||
<div id="agent-card-context" data-no-swipe="1">
|
||||
<div class="agent-card-context-label">{label}</div>
|
||||
<div class="agent-card-context-row">
|
||||
<div class="agent-card-context-main">
|
||||
<div class="agent-card-context-title">{title}</div>
|
||||
{meta && <div class="agent-card-context-meta">{meta}</div>}
|
||||
</div>
|
||||
<button
|
||||
class="agent-card-context-clear"
|
||||
type="button"
|
||||
aria-label="Clear selected card context"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSwipeHandlers(
|
||||
composing: boolean,
|
||||
view: WorkspaceView,
|
||||
setView: (view: WorkspaceView) => void,
|
||||
isInteractiveTarget: (target: EventTarget | null) => boolean,
|
||||
) {
|
||||
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const onSwipeStart = useCallback(
|
||||
(e: Event) => {
|
||||
const pe = e as PointerEvent;
|
||||
if (composing) return;
|
||||
if (pe.pointerType === "mouse" && pe.button !== 0) return;
|
||||
if (isInteractiveTarget(pe.target)) return;
|
||||
swipeStartRef.current = { x: pe.clientX, y: pe.clientY };
|
||||
},
|
||||
[composing, isInteractiveTarget],
|
||||
);
|
||||
|
||||
const onSwipeEnd = useCallback(
|
||||
(e: Event) => {
|
||||
const pe = e as PointerEvent;
|
||||
const start = swipeStartRef.current;
|
||||
swipeStartRef.current = null;
|
||||
if (!start || composing) return;
|
||||
const dx = pe.clientX - start.x;
|
||||
const dy = pe.clientY - start.y;
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD_PX) return;
|
||||
if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO) return;
|
||||
if (view === "agent" && dx < 0) setView("feed");
|
||||
if (view === "feed" && dx > 0) setView("agent");
|
||||
},
|
||||
[composing, view, setView],
|
||||
);
|
||||
|
||||
return { onSwipeStart, onSwipeEnd };
|
||||
}
|
||||
|
||||
function useCardActions(
|
||||
setView: (view: WorkspaceView) => void,
|
||||
setSelectedCardId: (cardId: string | null) => void,
|
||||
) {
|
||||
const handleAskCard = useCallback(
|
||||
(card: CardItem) => {
|
||||
if (!card.serverId) return;
|
||||
setSelectedCardId(card.serverId);
|
||||
setView("agent");
|
||||
},
|
||||
[setSelectedCardId, setView],
|
||||
);
|
||||
|
||||
return { handleAskCard };
|
||||
}
|
||||
|
||||
function 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?");
|
||||
if (!confirmed) return;
|
||||
await rtc.connect();
|
||||
rtc.sendJson({ type: "command", command: "reset" });
|
||||
}, [rtc]);
|
||||
|
||||
const handleToggleTextOnly = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
rtc.setTextOnly(enabled);
|
||||
if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
|
||||
},
|
||||
[rtc],
|
||||
);
|
||||
|
||||
return { handleReset, handleToggleTextOnly };
|
||||
}
|
||||
|
||||
function useSelectedCardActions({
|
||||
function AgentPanel({
|
||||
app,
|
||||
rtc,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
selectedCardMetadata,
|
||||
sessionDrawerOpen,
|
||||
setSessionDrawerOpen,
|
||||
sessionDrawerEdgeGesture,
|
||||
activeTheme,
|
||||
themeOptions,
|
||||
onSelectTheme,
|
||||
}: {
|
||||
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 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 ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
|
||||
[cards, selectedCardId],
|
||||
);
|
||||
const selectedCardSelection = useMemo(
|
||||
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
|
||||
[selectedCardId, selectionVersion],
|
||||
);
|
||||
const selectedCardMetadata = useCallback(
|
||||
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
|
||||
[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;
|
||||
app: ReturnType<typeof useAppPresentation>;
|
||||
rtc: ReturnType<typeof useWebRTC>;
|
||||
sessionDrawerOpen: boolean;
|
||||
setSessionDrawerOpen(open: boolean): void;
|
||||
sessionDrawerEdgeGesture: ReturnType<typeof useSessionDrawerEdgeSwipe>;
|
||||
activeTheme: ThemeName;
|
||||
themeOptions: ThemeOption[];
|
||||
onSelectTheme(themeName: ThemeName): void;
|
||||
}) {
|
||||
return (
|
||||
<section class="workspace-panel workspace-agent">
|
||||
{active && (
|
||||
<ControlBar onReset={onReset} textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />
|
||||
)}
|
||||
{active && selectedCard && (
|
||||
<AgentCardContext
|
||||
card={selectedCard}
|
||||
selection={selectedCardSelection}
|
||||
onClear={onClearSelectedCardContext}
|
||||
<AgentWorkspace
|
||||
active={app.view === "agent"}
|
||||
selectedCard={app.selectedCard}
|
||||
selectedCardSelection={app.selectedCardSelection}
|
||||
selectedCardContextLabel={app.selectedCardContextLabel}
|
||||
onClearSelectedCardContext={app.clearSelectedCardContext}
|
||||
textOnly={rtc.textOnly}
|
||||
onToggleTextOnly={app.handleToggleTextOnly}
|
||||
sessionDrawerEdge={
|
||||
!sessionDrawerOpen ? (
|
||||
<div
|
||||
id="session-drawer-edge"
|
||||
data-no-swipe="1"
|
||||
onTouchStart={sessionDrawerEdgeGesture.onTouchStart}
|
||||
onTouchMove={sessionDrawerEdgeGesture.onTouchMove}
|
||||
onTouchEnd={sessionDrawerEdgeGesture.onTouchEnd}
|
||||
onTouchCancel={sessionDrawerEdgeGesture.onTouchCancel}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
logLines={rtc.logLines}
|
||||
connected={rtc.connected}
|
||||
onSendMessage={app.handleSendMessage}
|
||||
effectiveAgentState={app.effectiveAgentState}
|
||||
textStreaming={rtc.textStreaming}
|
||||
connecting={rtc.connecting}
|
||||
audioLevel={app.audioLevel}
|
||||
sessionDrawer={
|
||||
<SessionDrawer
|
||||
open={sessionDrawerOpen}
|
||||
progress={sessionDrawerEdgeGesture.progress}
|
||||
dragging={sessionDrawerEdgeGesture.progress > 0 && !sessionDrawerOpen}
|
||||
sessions={rtc.sessions}
|
||||
activeSessionId={rtc.activeSessionId}
|
||||
busy={rtc.sessionLoading || rtc.textStreaming}
|
||||
onClose={() => setSessionDrawerOpen(false)}
|
||||
onCreate={async () => {
|
||||
await rtc.createSession();
|
||||
setSessionDrawerOpen(false);
|
||||
}}
|
||||
onSelect={async (chatId) => {
|
||||
await rtc.switchSession(chatId);
|
||||
setSessionDrawerOpen(false);
|
||||
}}
|
||||
onRename={async (chatId, title) => {
|
||||
await rtc.renameSession(chatId, title);
|
||||
}}
|
||||
onDelete={async (chatId) => {
|
||||
await rtc.deleteSession(chatId);
|
||||
setSessionDrawerOpen(false);
|
||||
}}
|
||||
activeTheme={activeTheme}
|
||||
themeOptions={themeOptions}
|
||||
onSelectTheme={onSelectTheme}
|
||||
/>
|
||||
)}
|
||||
{active && (
|
||||
<LogPanel
|
||||
lines={logLines}
|
||||
disabled={!connected}
|
||||
onSendMessage={onSendMessage}
|
||||
onExpandChange={onExpandChange}
|
||||
}
|
||||
workbenchOverlay={
|
||||
<WorkbenchOverlay
|
||||
items={rtc.workbenchItems}
|
||||
onDismiss={rtc.dismissWorkbenchItem}
|
||||
onPromote={rtc.promoteWorkbenchItem}
|
||||
/>
|
||||
)}
|
||||
<AgentIndicator
|
||||
state={effectiveAgentState}
|
||||
connected={connected}
|
||||
connecting={connecting}
|
||||
audioLevel={audioLevel}
|
||||
viewActive
|
||||
onPointerDown={() => {}}
|
||||
onPointerUp={() => {}}
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedWorkspace({
|
||||
cards,
|
||||
onDismiss,
|
||||
onChoice,
|
||||
onAskCard,
|
||||
function FeedPanel({
|
||||
app,
|
||||
rtc,
|
||||
}: {
|
||||
cards: CardItem[];
|
||||
onDismiss(id: number): void;
|
||||
onChoice(cardId: string, value: string): void;
|
||||
onAskCard(card: CardItem): void;
|
||||
app: ReturnType<typeof useAppPresentation>;
|
||||
rtc: ReturnType<typeof useWebRTC>;
|
||||
}) {
|
||||
return (
|
||||
<section class="workspace-panel workspace-feed">
|
||||
<CardFeed
|
||||
cards={cards}
|
||||
viewActive
|
||||
onDismiss={onDismiss}
|
||||
onChoice={onChoice}
|
||||
onAskCard={onAskCard}
|
||||
/>
|
||||
</section>
|
||||
<FeedWorkspace
|
||||
cards={rtc.cards}
|
||||
onDismiss={rtc.dismissCard}
|
||||
onChoice={app.handleCardChoice}
|
||||
onAskCard={app.handleAskCard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
currentAgentState: rtc.agentState,
|
||||
onSendPtt: (pressed) =>
|
||||
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
|
||||
onBootstrap: rtc.connect,
|
||||
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
|
||||
});
|
||||
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
|
||||
|
||||
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("button,textarea,input,a,[data-no-swipe='1']"));
|
||||
}, []);
|
||||
const { onSwipeStart, onSwipeEnd } = useSwipeHandlers(
|
||||
composing,
|
||||
view,
|
||||
setView,
|
||||
isInteractiveTarget,
|
||||
);
|
||||
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
|
||||
useCardSelectionLifecycle({
|
||||
cards: rtc.cards,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
setSelectionVersion,
|
||||
setView,
|
||||
});
|
||||
|
||||
const { handleToggleTextOnly } = useControlActions(rtc);
|
||||
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
|
||||
const {
|
||||
clearSelectedCardContext,
|
||||
handleCardChoice,
|
||||
handleSendMessage,
|
||||
handleResetWithSelection,
|
||||
} = useSelectedCardActions({
|
||||
rtc,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
selectedCardMetadata,
|
||||
const app = useAppPresentation(rtc);
|
||||
const theme = useThemePreference();
|
||||
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
|
||||
const sessionDrawerEdgeGesture = useSessionDrawerEdgeSwipe({
|
||||
enabled: app.view === "agent" && !sessionDrawerOpen,
|
||||
onOpen: () => setSessionDrawerOpen(true),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
|
||||
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
|
||||
<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}
|
||||
<SwipeWorkspace
|
||||
view={app.view}
|
||||
trackStyle={app.trackStyle}
|
||||
onSwipeStart={app.onSwipeStart}
|
||||
onSwipeMove={app.onSwipeMove}
|
||||
onSwipeEnd={app.onSwipeEnd}
|
||||
onSwipeCancel={app.onSwipeCancel}
|
||||
onTouchStart={app.onTouchStart}
|
||||
onTouchMove={app.onTouchMove}
|
||||
onTouchEnd={app.onTouchEnd}
|
||||
onTouchCancel={app.onTouchCancel}
|
||||
agentWorkspace={
|
||||
<AgentPanel
|
||||
app={app}
|
||||
rtc={rtc}
|
||||
sessionDrawerOpen={sessionDrawerOpen}
|
||||
setSessionDrawerOpen={setSessionDrawerOpen}
|
||||
sessionDrawerEdgeGesture={sessionDrawerEdgeGesture}
|
||||
activeTheme={theme.themeName}
|
||||
themeOptions={theme.themeOptions}
|
||||
onSelectTheme={theme.setThemeName}
|
||||
/>
|
||||
<FeedWorkspace
|
||||
cards={rtc.cards}
|
||||
onDismiss={rtc.dismissCard}
|
||||
onChoice={handleCardChoice}
|
||||
onAskCard={handleAskCard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
feedWorkspace={<FeedPanel app={app} rtc={rtc} />}
|
||||
/>
|
||||
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
905
frontend/src/appShell/presentation.tsx
Normal file
905
frontend/src/appShell/presentation.tsx
Normal file
|
|
@ -0,0 +1,905 @@
|
|||
import type { ComponentChildren } from "preact";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import {
|
||||
clearCardSelection,
|
||||
getCardLiveContent,
|
||||
getCardSelection,
|
||||
subscribeCardSelection,
|
||||
} from "../cardRuntime/store";
|
||||
import { AgentIndicator } from "../components/AgentIndicator";
|
||||
import { CardFeed } from "../components/CardFeed";
|
||||
import { ControlBar } from "../components/Controls";
|
||||
import { LogPanel } from "../components/LogPanel";
|
||||
import { useAudioMeter } from "../hooks/useAudioMeter";
|
||||
import { usePTT } from "../hooks/usePTT";
|
||||
import type { WebRTCState } from "../hooks/webrtc/types";
|
||||
import type {
|
||||
AgentState,
|
||||
CardItem,
|
||||
CardMessageMetadata,
|
||||
CardSelectionRange,
|
||||
JsonValue,
|
||||
LogLine,
|
||||
} from "../types";
|
||||
|
||||
const SWIPE_THRESHOLD_PX = 64;
|
||||
const SWIPE_INTENT_PX = 12;
|
||||
const SWIPE_COMMIT_RATIO = 0.18;
|
||||
const SWIPE_DIRECTION_RATIO = 1.15;
|
||||
const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52;
|
||||
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
|
||||
const SESSION_DRAWER_GUTTER_PX = 28;
|
||||
|
||||
type WorkspaceView = "agent" | "feed";
|
||||
|
||||
interface AppRtcActions {
|
||||
connect(): Promise<void>;
|
||||
sendJson(
|
||||
msg:
|
||||
| { type: "command"; command: string }
|
||||
| { type: "card-response"; card_id: string; value: string }
|
||||
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
|
||||
): void;
|
||||
setTextOnly(enabled: boolean): void;
|
||||
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
interface SwipeState {
|
||||
pointerId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
interface EdgeSwipeState {
|
||||
identifier: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open";
|
||||
|
||||
function findTouchById(touches: TouchList, identifier: number): Touch | null {
|
||||
for (let i = 0; i < touches.length; i += 1) {
|
||||
const touch = touches.item(i);
|
||||
if (touch && touch.identifier === identifier) return touch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSwipeInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(
|
||||
target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"),
|
||||
);
|
||||
}
|
||||
|
||||
function getViewportWidth(): number {
|
||||
return window.innerWidth || document.documentElement.clientWidth || 1;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: JsonValue | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
|
||||
const raw = getCardSelection(cardId);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, JsonValue>;
|
||||
if (record.kind !== "git_diff_range") return null;
|
||||
if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
|
||||
|
||||
const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
|
||||
const label =
|
||||
typeof record.label === "string"
|
||||
? record.label
|
||||
: `${record.file_label} · ${record.range_label}`;
|
||||
|
||||
return {
|
||||
kind: "git_diff_range",
|
||||
file_path: filePath,
|
||||
file_label: record.file_label,
|
||||
range_label: record.range_label,
|
||||
label,
|
||||
old_start: toNullableNumber(record.old_start),
|
||||
old_end: toNullableNumber(record.old_end),
|
||||
new_start: toNullableNumber(record.new_start),
|
||||
new_end: toNullableNumber(record.new_end),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCardMetadata(card: CardItem): CardMessageMetadata {
|
||||
const metadata: CardMessageMetadata = {
|
||||
card_id: card.serverId,
|
||||
card_slot: card.slot,
|
||||
card_title: card.title,
|
||||
card_lane: card.lane,
|
||||
card_template_key: card.templateKey,
|
||||
card_context_summary: card.contextSummary,
|
||||
card_response_value: card.responseValue,
|
||||
};
|
||||
const selection = card.serverId ? readCardSelection(card.serverId) : null;
|
||||
if (selection) {
|
||||
metadata.card_selection = selection as unknown as JsonValue;
|
||||
metadata.card_selection_label = selection.label;
|
||||
}
|
||||
const liveContent = card.serverId ? getCardLiveContent(card.serverId) : undefined;
|
||||
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function formatCardContextLabel(
|
||||
card: CardItem | null,
|
||||
selection: CardSelectionRange | null,
|
||||
): string | null {
|
||||
if (!card) return null;
|
||||
if (selection) {
|
||||
return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`;
|
||||
}
|
||||
const title = card.title?.trim();
|
||||
if (!title) return "card";
|
||||
if (card.templateKey === "todo-item-live") return `task: ${title}`;
|
||||
if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`;
|
||||
if (card.templateKey === "list-total-live") return `tracker: ${title}`;
|
||||
return `card: ${title}`;
|
||||
}
|
||||
|
||||
function AgentCardContext({
|
||||
card,
|
||||
selection,
|
||||
onClear,
|
||||
textMode = false,
|
||||
}: {
|
||||
card: CardItem;
|
||||
selection: CardSelectionRange | null;
|
||||
onClear(): void;
|
||||
textMode?: boolean;
|
||||
}) {
|
||||
const label = textMode
|
||||
? "Next message uses this context"
|
||||
: selection
|
||||
? "Using diff context"
|
||||
: "Using card";
|
||||
const title = selection?.file_label || card.title;
|
||||
const meta = selection?.range_label || card.contextSummary || "";
|
||||
|
||||
return (
|
||||
<div id="agent-card-context" data-no-swipe="1">
|
||||
<div class="agent-card-context-label">{label}</div>
|
||||
<div class="agent-card-context-row">
|
||||
<div class="agent-card-context-main">
|
||||
<div class="agent-card-context-title">{title}</div>
|
||||
{meta && <div class="agent-card-context-meta">{meta}</div>}
|
||||
{textMode ? (
|
||||
<div class="agent-card-context-note">
|
||||
Ask your follow-up and Nanobot will include this card in the conversation context.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
class="agent-card-context-clear"
|
||||
type="button"
|
||||
aria-label="Clear selected card context"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } {
|
||||
return {
|
||||
dx: event.clientX - swipe.x,
|
||||
dy: event.clientY - swipe.y,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSwipeIntent(dx: number, dy: number): boolean {
|
||||
return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX;
|
||||
}
|
||||
|
||||
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
|
||||
return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO;
|
||||
}
|
||||
|
||||
function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number {
|
||||
const minOffset = view === "agent" ? -width : 0;
|
||||
const maxOffset = view === "agent" ? 0 : width;
|
||||
return Math.max(minOffset, Math.min(maxOffset, dx));
|
||||
}
|
||||
|
||||
function getCommittedSwipeView(
|
||||
view: WorkspaceView,
|
||||
dx: number,
|
||||
width: number,
|
||||
): WorkspaceView | null {
|
||||
const progress = width > 0 ? Math.abs(dx) / width : 0;
|
||||
const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO;
|
||||
if (!crossedThreshold) return null;
|
||||
if (view === "agent" && dx < 0) return "feed";
|
||||
if (view === "feed" && dx > 0) return "agent";
|
||||
return null;
|
||||
}
|
||||
|
||||
function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void {
|
||||
const element = currentTarget as HTMLElement | null;
|
||||
if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
|
||||
}
|
||||
|
||||
export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) {
|
||||
return useMemo(() => {
|
||||
const base = view === "feed" ? "-50%" : "0%";
|
||||
return {
|
||||
transform:
|
||||
swipeOffsetPx === 0
|
||||
? `translateX(${base})`
|
||||
: `translateX(calc(${base} + ${swipeOffsetPx}px))`,
|
||||
transition: swiping ? "none" : "transform 0.28s ease",
|
||||
};
|
||||
}, [swipeOffsetPx, swiping, view]);
|
||||
}
|
||||
|
||||
function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome {
|
||||
if (!hasSwipeIntent(dx, dy)) return "idle";
|
||||
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel";
|
||||
if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track";
|
||||
return "open";
|
||||
}
|
||||
|
||||
function getEdgeSwipeTouch(
|
||||
touches: TouchList,
|
||||
swipe: EdgeSwipeState | null,
|
||||
): { swipe: EdgeSwipeState; touch: Touch } | null {
|
||||
if (!swipe) return null;
|
||||
const touch = findTouchById(touches, swipe.identifier);
|
||||
if (!touch) return null;
|
||||
return { swipe, touch };
|
||||
}
|
||||
|
||||
function getSessionDrawerWidth(viewportWidth: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
|
||||
);
|
||||
}
|
||||
|
||||
function getSessionDrawerProgress(dx: number, viewportWidth: number): number {
|
||||
return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth)));
|
||||
}
|
||||
|
||||
export function useSessionDrawerEdgeSwipe({
|
||||
enabled,
|
||||
onOpen,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onOpen(): void;
|
||||
}) {
|
||||
const edgeSwipeRef = useRef<EdgeSwipeState | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const clearEdgeSwipe = useCallback(() => {
|
||||
edgeSwipeRef.current = null;
|
||||
setProgress(0);
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: Event) => {
|
||||
if (!enabled) return;
|
||||
const te = e as TouchEvent;
|
||||
if (te.touches.length !== 1) return;
|
||||
const touch = te.touches.item(0);
|
||||
if (!touch) return;
|
||||
edgeSwipeRef.current = {
|
||||
identifier: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
};
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: Event) => {
|
||||
const te = e as TouchEvent;
|
||||
if (!enabled) return;
|
||||
const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current);
|
||||
if (!trackedTouch) return;
|
||||
|
||||
const { swipe, touch } = trackedTouch;
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
const outcome = getEdgeSwipeOutcome(dx, dy);
|
||||
if (outcome === "idle") return;
|
||||
|
||||
if (outcome === "cancel") {
|
||||
clearEdgeSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (te.cancelable) te.preventDefault();
|
||||
setProgress(
|
||||
getSessionDrawerProgress(
|
||||
dx,
|
||||
window.innerWidth || document.documentElement.clientWidth || 1,
|
||||
),
|
||||
);
|
||||
if (outcome === "track") return;
|
||||
},
|
||||
[clearEdgeSwipe, enabled],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: Event) => {
|
||||
const te = e as TouchEvent;
|
||||
const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current);
|
||||
if (!trackedTouch) {
|
||||
clearEdgeSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const { swipe, touch } = trackedTouch;
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX;
|
||||
clearEdgeSwipe();
|
||||
if (shouldOpen) onOpen();
|
||||
},
|
||||
[clearEdgeSwipe, onOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel: clearEdgeSwipe,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here
|
||||
export function useSwipeHandlers(
|
||||
view: WorkspaceView,
|
||||
setView: (view: WorkspaceView) => void,
|
||||
isInteractiveTarget: (target: EventTarget | null) => boolean,
|
||||
getViewportWidthFn: () => number,
|
||||
) {
|
||||
const swipeRef = useRef<SwipeState | null>(null);
|
||||
const [swipeOffsetPx, setSwipeOffsetPx] = useState(0);
|
||||
const [swiping, setSwiping] = useState(false);
|
||||
|
||||
const clearSwipe = useCallback(() => {
|
||||
swipeRef.current = null;
|
||||
setSwiping(false);
|
||||
setSwipeOffsetPx(0);
|
||||
}, []);
|
||||
|
||||
const onSwipeStart = useCallback(
|
||||
(e: Event) => {
|
||||
const pe = e as PointerEvent;
|
||||
if (pe.pointerType === "touch") return;
|
||||
if (pe.pointerType === "mouse" && pe.button !== 0) return;
|
||||
if (isInteractiveTarget(pe.target)) return;
|
||||
swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false };
|
||||
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId);
|
||||
},
|
||||
[isInteractiveTarget],
|
||||
);
|
||||
|
||||
const onSwipeMove = useCallback(
|
||||
(e: Event) => {
|
||||
const pe = e as PointerEvent;
|
||||
if (pe.pointerType === "touch") return;
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe || swipe.pointerId !== pe.pointerId) return;
|
||||
|
||||
const { dx, dy } = getSwipeDelta(swipe, pe);
|
||||
|
||||
if (!swipe.dragging) {
|
||||
if (!hasSwipeIntent(dx, dy)) return;
|
||||
if (isVerticalSwipeDominant(dx, dy)) {
|
||||
clearSwipe();
|
||||
return;
|
||||
}
|
||||
swipe.dragging = true;
|
||||
setSwiping(true);
|
||||
}
|
||||
|
||||
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
|
||||
if (pe.cancelable) pe.preventDefault();
|
||||
},
|
||||
[clearSwipe, getViewportWidthFn, view],
|
||||
);
|
||||
|
||||
const finishSwipe = useCallback(
|
||||
(e: Event, commit: boolean) => {
|
||||
const pe = e as PointerEvent;
|
||||
if (pe.pointerType === "touch") return;
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe || swipe.pointerId !== pe.pointerId) return;
|
||||
|
||||
releaseCapturedPointer(e.currentTarget, pe.pointerId);
|
||||
if (commit) {
|
||||
const { dx } = getSwipeDelta(swipe, pe);
|
||||
const nextView = swipe.dragging
|
||||
? getCommittedSwipeView(view, dx, getViewportWidthFn())
|
||||
: null;
|
||||
if (nextView) setView(nextView);
|
||||
}
|
||||
|
||||
clearSwipe();
|
||||
},
|
||||
[clearSwipe, getViewportWidthFn, setView, view],
|
||||
);
|
||||
|
||||
const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]);
|
||||
const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: Event) => {
|
||||
const te = e as TouchEvent;
|
||||
if (te.touches.length !== 1) return;
|
||||
const touch = te.touches.item(0);
|
||||
if (!touch) return;
|
||||
if (isInteractiveTarget(te.target)) return;
|
||||
swipeRef.current = {
|
||||
pointerId: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
dragging: false,
|
||||
};
|
||||
},
|
||||
[isInteractiveTarget],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: Event) => {
|
||||
const te = e as TouchEvent;
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe) return;
|
||||
const touch = findTouchById(te.touches, swipe.pointerId);
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
|
||||
if (!swipe.dragging) {
|
||||
if (!hasSwipeIntent(dx, dy)) return;
|
||||
if (isVerticalSwipeDominant(dx, dy)) {
|
||||
clearSwipe();
|
||||
return;
|
||||
}
|
||||
swipe.dragging = true;
|
||||
setSwiping(true);
|
||||
}
|
||||
|
||||
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
|
||||
if (te.cancelable) te.preventDefault();
|
||||
},
|
||||
[clearSwipe, getViewportWidthFn, view],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: Event) => {
|
||||
const te = e as TouchEvent;
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe) return;
|
||||
const touch = findTouchById(te.changedTouches, swipe.pointerId);
|
||||
if (!touch) return;
|
||||
|
||||
if (swipe.dragging) {
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn());
|
||||
if (nextView) setView(nextView);
|
||||
}
|
||||
|
||||
clearSwipe();
|
||||
},
|
||||
[clearSwipe, getViewportWidthFn, setView, view],
|
||||
);
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
clearSwipe();
|
||||
}, [clearSwipe]);
|
||||
|
||||
return {
|
||||
onSwipeStart,
|
||||
onSwipeMove,
|
||||
onSwipeEnd,
|
||||
onSwipeCancel,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel,
|
||||
swipeOffsetPx,
|
||||
swiping,
|
||||
};
|
||||
}
|
||||
|
||||
function useCardActions(
|
||||
setView: (view: WorkspaceView) => void,
|
||||
setSelectedCardId: (cardId: string | null) => void,
|
||||
) {
|
||||
const handleAskCard = useCallback(
|
||||
(card: CardItem) => {
|
||||
if (!card.serverId) return;
|
||||
setSelectedCardId(card.serverId);
|
||||
setView("agent");
|
||||
},
|
||||
[setSelectedCardId, setView],
|
||||
);
|
||||
|
||||
return { handleAskCard };
|
||||
}
|
||||
|
||||
function 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 handleToggleTextOnly = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
rtc.setTextOnly(enabled);
|
||||
if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
|
||||
},
|
||||
[rtc],
|
||||
);
|
||||
|
||||
return { handleToggleTextOnly };
|
||||
}
|
||||
|
||||
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) clearCardSelection(selectedCardId);
|
||||
setSelectedCardId(null);
|
||||
}, [selectedCardId, setSelectedCardId]);
|
||||
|
||||
const handleCardChoice = useCallback(
|
||||
(cardId: string, value: string) => {
|
||||
rtc.sendJson({ type: "card-response", card_id: cardId, value });
|
||||
},
|
||||
[rtc],
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
await rtc.sendTextMessage(text, selectedCardMetadata());
|
||||
},
|
||||
[rtc, selectedCardMetadata],
|
||||
);
|
||||
|
||||
return {
|
||||
clearSelectedCardContext,
|
||||
handleCardChoice,
|
||||
handleSendMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function useSelectedCardContext(
|
||||
cards: CardItem[],
|
||||
selectedCardId: string | null,
|
||||
selectionVersion: number,
|
||||
) {
|
||||
const selectedCard = useMemo(
|
||||
() =>
|
||||
selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
|
||||
[cards, selectedCardId],
|
||||
);
|
||||
const selectedCardSelection = useMemo(
|
||||
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
|
||||
[selectedCardId, selectionVersion],
|
||||
);
|
||||
const selectedCardMetadata = useCallback(
|
||||
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
|
||||
[selectedCard, selectionVersion],
|
||||
);
|
||||
|
||||
return { selectedCard, selectedCardSelection, selectedCardMetadata };
|
||||
}
|
||||
|
||||
function useCardSelectionLifecycle({
|
||||
cards,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
setSelectionVersion,
|
||||
}: {
|
||||
cards: CardItem[];
|
||||
selectedCardId: string | null;
|
||||
setSelectedCardId: (cardId: string | null) => void;
|
||||
setSelectionVersion: (updater: (current: number) => number) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!selectedCardId) return;
|
||||
if (cards.some((card) => card.serverId === selectedCardId)) return;
|
||||
setSelectedCardId(null);
|
||||
}, [cards, selectedCardId, setSelectedCardId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeCardSelection((cardId, selection) => {
|
||||
setSelectionVersion((current) => current + 1);
|
||||
if (cardId && selection) setSelectedCardId(cardId);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [setSelectedCardId, setSelectionVersion]);
|
||||
}
|
||||
|
||||
export function AgentWorkspace({
|
||||
active,
|
||||
selectedCard,
|
||||
selectedCardSelection,
|
||||
selectedCardContextLabel,
|
||||
onClearSelectedCardContext,
|
||||
textOnly,
|
||||
onToggleTextOnly,
|
||||
sessionDrawerEdge,
|
||||
logLines,
|
||||
connected,
|
||||
onSendMessage,
|
||||
effectiveAgentState,
|
||||
textStreaming,
|
||||
connecting,
|
||||
audioLevel,
|
||||
sessionDrawer,
|
||||
workbenchOverlay,
|
||||
}: {
|
||||
active: boolean;
|
||||
selectedCard: CardItem | null;
|
||||
selectedCardSelection: CardSelectionRange | null;
|
||||
selectedCardContextLabel: string | null;
|
||||
onClearSelectedCardContext(): void;
|
||||
textOnly: boolean;
|
||||
onToggleTextOnly(enabled: boolean): Promise<void>;
|
||||
sessionDrawerEdge: ComponentChildren;
|
||||
logLines: LogLine[];
|
||||
connected: boolean;
|
||||
onSendMessage(text: string): Promise<void>;
|
||||
effectiveAgentState: AgentState;
|
||||
textStreaming: boolean;
|
||||
connecting: boolean;
|
||||
audioLevel: number;
|
||||
sessionDrawer: ComponentChildren;
|
||||
workbenchOverlay: ComponentChildren;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
class={`workspace-panel workspace-agent${textOnly ? " text-mode" : ""}${
|
||||
textOnly && selectedCard ? " has-card-context" : ""
|
||||
}`}
|
||||
>
|
||||
{active && <ControlBar textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />}
|
||||
{active && sessionDrawerEdge}
|
||||
{active && sessionDrawer}
|
||||
{active && workbenchOverlay}
|
||||
{active && selectedCard && !textOnly && (
|
||||
<AgentCardContext
|
||||
card={selectedCard}
|
||||
selection={selectedCardSelection}
|
||||
onClear={onClearSelectedCardContext}
|
||||
textMode={textOnly}
|
||||
/>
|
||||
)}
|
||||
{active && (
|
||||
<LogPanel
|
||||
lines={logLines}
|
||||
disabled={false}
|
||||
onSendMessage={onSendMessage}
|
||||
onOpenVoiceMode={() => {
|
||||
void onToggleTextOnly(false);
|
||||
}}
|
||||
contextLabel={selectedCardContextLabel}
|
||||
onClearContext={selectedCard ? onClearSelectedCardContext : undefined}
|
||||
agentState={effectiveAgentState}
|
||||
textStreaming={textStreaming}
|
||||
fullScreen={textOnly}
|
||||
/>
|
||||
)}
|
||||
{!textOnly && (
|
||||
<AgentIndicator
|
||||
state={effectiveAgentState}
|
||||
connected={connected}
|
||||
connecting={connecting}
|
||||
audioLevel={audioLevel}
|
||||
viewActive
|
||||
onPointerDown={() => {}}
|
||||
onPointerUp={() => {}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export 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 SwipeWorkspace({
|
||||
view,
|
||||
trackStyle,
|
||||
onSwipeStart,
|
||||
onSwipeMove,
|
||||
onSwipeEnd,
|
||||
onSwipeCancel,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel,
|
||||
agentWorkspace,
|
||||
feedWorkspace,
|
||||
}: {
|
||||
view: WorkspaceView;
|
||||
trackStyle: Record<string, string>;
|
||||
onSwipeStart: (event: Event) => void;
|
||||
onSwipeMove: (event: Event) => void;
|
||||
onSwipeEnd: (event: Event) => void;
|
||||
onSwipeCancel: (event: Event) => void;
|
||||
onTouchStart: (event: Event) => void;
|
||||
onTouchMove: (event: Event) => void;
|
||||
onTouchEnd: (event: Event) => void;
|
||||
onTouchCancel: (event: Event) => void;
|
||||
agentWorkspace: ComponentChildren;
|
||||
feedWorkspace: ComponentChildren;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id="swipe-shell"
|
||||
onPointerDown={onSwipeStart}
|
||||
onPointerMove={onSwipeMove}
|
||||
onPointerUp={onSwipeEnd}
|
||||
onPointerCancel={onSwipeCancel}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchCancel}
|
||||
>
|
||||
<div id="swipe-track" data-view={view} style={trackStyle}>
|
||||
{agentWorkspace}
|
||||
{feedWorkspace}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppPresentation(rtc: WebRTCState) {
|
||||
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
|
||||
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
|
||||
|
||||
const [view, setView] = useState<WorkspaceView>("agent");
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
||||
const [selectionVersion, setSelectionVersion] = useState(0);
|
||||
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
|
||||
rtc.cards,
|
||||
selectedCardId,
|
||||
selectionVersion,
|
||||
);
|
||||
const selectedCardContextLabel = useMemo(
|
||||
() => formatCardContextLabel(selectedCard, selectedCardSelection),
|
||||
[selectedCard, selectedCardSelection],
|
||||
);
|
||||
|
||||
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
|
||||
connected: rtc.connected && !rtc.textOnly,
|
||||
currentAgentState: rtc.agentState,
|
||||
onSendPtt: (pressed) =>
|
||||
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
|
||||
onBootstrap: rtc.textOnly ? async () => {} : rtc.connect,
|
||||
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
|
||||
});
|
||||
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
|
||||
const {
|
||||
onSwipeStart,
|
||||
onSwipeMove,
|
||||
onSwipeEnd,
|
||||
onSwipeCancel,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel,
|
||||
swipeOffsetPx,
|
||||
swiping,
|
||||
} = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth);
|
||||
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
|
||||
useCardSelectionLifecycle({
|
||||
cards: rtc.cards,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
setSelectionVersion,
|
||||
});
|
||||
|
||||
const { handleToggleTextOnly } = useControlActions(rtc);
|
||||
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
|
||||
const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({
|
||||
rtc,
|
||||
selectedCardId,
|
||||
setSelectedCardId,
|
||||
selectedCardMetadata: useCallback(() => {
|
||||
const metadata = selectedCardMetadata();
|
||||
if (!metadata) return metadata;
|
||||
return {
|
||||
...metadata,
|
||||
context_label: selectedCardContextLabel ?? undefined,
|
||||
};
|
||||
}, [selectedCardContextLabel, selectedCardMetadata]),
|
||||
});
|
||||
|
||||
const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping);
|
||||
|
||||
return {
|
||||
audioLevel,
|
||||
view,
|
||||
selectedCard,
|
||||
selectedCardSelection,
|
||||
selectedCardContextLabel,
|
||||
effectiveAgentState,
|
||||
handleToggleTextOnly,
|
||||
handleAskCard,
|
||||
clearSelectedCardContext,
|
||||
handleCardChoice,
|
||||
handleSendMessage,
|
||||
onSwipeStart,
|
||||
onSwipeMove,
|
||||
onSwipeEnd,
|
||||
onSwipeCancel,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel,
|
||||
trackStyle,
|
||||
};
|
||||
}
|
||||
363
frontend/src/cardRuntime/api.ts
Normal file
363
frontend/src/cardRuntime/api.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { streamSseResponse } from "../lib/sse";
|
||||
import type { JsonValue, WorkbenchItem } from "../types";
|
||||
|
||||
export interface ManualToolResult {
|
||||
tool_name: string;
|
||||
content: string;
|
||||
parsed: JsonValue | null;
|
||||
is_json: boolean;
|
||||
}
|
||||
|
||||
export interface ManualToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface ManualToolAsyncOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export 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 {
|
||||
// Fall back to 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: normalizeManualToolJobResult(record.result, toolName),
|
||||
error: typeof record.error === "string" ? record.error : null,
|
||||
error_code: typeof record.error_code === "number" ? record.error_code : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManualToolAsyncOptions(
|
||||
options: ManualToolAsyncOptions,
|
||||
): Required<ManualToolAsyncOptions> {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === "number" &&
|
||||
Number.isFinite(options.timeoutMs) &&
|
||||
options.timeoutMs >= 100
|
||||
? Math.round(options.timeoutMs)
|
||||
: 30000;
|
||||
return { timeoutMs };
|
||||
}
|
||||
|
||||
function normalizeManualToolJobResult(
|
||||
payload: unknown,
|
||||
fallbackName: string,
|
||||
): ManualToolResult | null {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const looksNormalized =
|
||||
typeof record.tool_name === "string" ||
|
||||
typeof record.content === "string" ||
|
||||
record.parsed !== undefined ||
|
||||
typeof record.is_json === "boolean";
|
||||
if (looksNormalized) {
|
||||
return normalizeManualToolResult(record as Partial<ManualToolResult>, fallbackName);
|
||||
}
|
||||
return {
|
||||
tool_name: fallbackName,
|
||||
content: JSON.stringify(record),
|
||||
parsed: cloneJsonValue(record as JsonValue) ?? null,
|
||||
is_json: true,
|
||||
};
|
||||
}
|
||||
|
||||
function completedManualToolResult(
|
||||
job: ManualToolJob,
|
||||
toolName: string,
|
||||
): ManualToolResult | undefined {
|
||||
if (job.status !== "completed") return undefined;
|
||||
return job.result ?? normalizeManualToolResult(null, toolName);
|
||||
}
|
||||
|
||||
function assertManualToolJobDidNotFail(job: ManualToolJob, toolName: string): void {
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || `${toolName} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function streamManualToolJobUpdates(
|
||||
jobId: string,
|
||||
toolName: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ManualToolJob | null> {
|
||||
let current: ManualToolJob | null = null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/tools/jobs/${encodeURIComponent(jobId)}/stream`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
|
||||
await streamSseResponse(resp, (raw) => {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.type !== "tool.job") return;
|
||||
current = normalizeManualToolJob(record.job, toolName);
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`${toolName} timed out`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
async function waitForManualToolJob(
|
||||
job: ManualToolJob,
|
||||
toolName: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ManualToolResult> {
|
||||
const immediate = completedManualToolResult(job, toolName);
|
||||
if (immediate) return immediate;
|
||||
assertManualToolJobDidNotFail(job, toolName);
|
||||
|
||||
const streamed = await streamManualToolJobUpdates(job.job_id, toolName, timeoutMs);
|
||||
const streamedResult = streamed ? completedManualToolResult(streamed, toolName) : undefined;
|
||||
if (streamedResult) return streamedResult;
|
||||
if (streamed) assertManualToolJobDidNotFail(streamed, toolName);
|
||||
|
||||
const current = await getManualToolJob(job.job_id);
|
||||
const finalResult = completedManualToolResult(current, toolName);
|
||||
if (finalResult) return finalResult;
|
||||
assertManualToolJobDidNotFail(current, toolName);
|
||||
throw new Error(`${toolName} ended before completion`);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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, "");
|
||||
}
|
||||
|
||||
export async function callManualToolAsync(
|
||||
toolName: string,
|
||||
argumentsValue: Record<string, JsonValue> = {},
|
||||
options: ManualToolAsyncOptions = {},
|
||||
): Promise<ManualToolResult> {
|
||||
const { timeoutMs } = normalizeManualToolAsyncOptions(options);
|
||||
const job = await startManualToolCall(toolName, argumentsValue);
|
||||
return waitForManualToolJob(job, toolName, timeoutMs);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export async function updateCardTemplateState(
|
||||
cardId: string,
|
||||
templateState: Record<string, JsonValue>,
|
||||
): Promise<void> {
|
||||
const key = cardId.trim();
|
||||
if (!key) throw new Error("card id is required");
|
||||
const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template_state: templateState }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
}
|
||||
|
||||
export async function updateWorkbenchTemplateState(
|
||||
item: WorkbenchItem,
|
||||
templateState: Record<string, JsonValue>,
|
||||
): Promise<void> {
|
||||
const resp = await fetch("/workbench", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: item.id,
|
||||
chat_id: item.chatId,
|
||||
kind: item.kind,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
question: item.question || "",
|
||||
choices: item.choices || [],
|
||||
response_value: item.responseValue || "",
|
||||
slot: item.slot || "",
|
||||
template_key: item.templateKey || "",
|
||||
template_state: templateState,
|
||||
context_summary: item.contextSummary || "",
|
||||
promotable: item.promotable,
|
||||
source_card_id: item.sourceCardId || "",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
81
frontend/src/cardRuntime/runtimeAssets.ts
Normal file
81
frontend/src/cardRuntime/runtimeAssets.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { JsonValue } from "../types";
|
||||
import type { RuntimeItem, RuntimeModule } from "./runtimeTypes";
|
||||
import { runtimeItemId } from "./runtimeUtils";
|
||||
|
||||
const runtimeAssetCache = new Map<
|
||||
string,
|
||||
Promise<{ html: string | null; module: RuntimeModule } | null>
|
||||
>();
|
||||
|
||||
function runtimeTemplateUrl(templateKey: string, filename: string): string {
|
||||
return `/card-templates/${encodeURIComponent(templateKey)}/${filename}`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function jsonScriptText(payload: Record<string, JsonValue>): string {
|
||||
return JSON.stringify(payload, null, 0).replace(/<\//g, "<\\/");
|
||||
}
|
||||
|
||||
export function materializeRuntimeHtml(
|
||||
templateHtml: string,
|
||||
item: RuntimeItem,
|
||||
state: Record<string, JsonValue>,
|
||||
): string {
|
||||
const cardId = escapeAttribute(runtimeItemId(item));
|
||||
const templateKey = escapeAttribute(item.templateKey?.trim() || "");
|
||||
return [
|
||||
`<div data-nanobot-card-root data-card-id="${cardId}" data-template-key="${templateKey}">`,
|
||||
`<script type="application/json" data-card-state>${jsonScriptText(state)}</script>`,
|
||||
templateHtml,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
export function syncRuntimeStateScript(
|
||||
root: HTMLDivElement | null,
|
||||
state: Record<string, JsonValue>,
|
||||
): void {
|
||||
const stateEl = root?.querySelector('script[data-card-state][type="application/json"]');
|
||||
if (!(stateEl instanceof HTMLScriptElement)) return;
|
||||
stateEl.textContent = jsonScriptText(state);
|
||||
}
|
||||
|
||||
export async function loadRuntimeAssets(
|
||||
templateKey: string,
|
||||
): Promise<{ html: string | null; module: RuntimeModule } | null> {
|
||||
const cached = runtimeAssetCache.get(templateKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const loader = (async () => {
|
||||
const moduleUrl = runtimeTemplateUrl(templateKey, "card.js");
|
||||
const moduleProbe = await fetch(moduleUrl, { cache: "no-store" });
|
||||
if (!moduleProbe.ok) return null;
|
||||
|
||||
const templatePromise = fetch(runtimeTemplateUrl(templateKey, "template.html"), {
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(async (response) => (response.ok ? response.text() : null))
|
||||
.catch(() => null);
|
||||
|
||||
const namespace = (await import(/* @vite-ignore */ `${moduleUrl}?runtime=1`)) as
|
||||
| RuntimeModule
|
||||
| { default?: RuntimeModule };
|
||||
const runtimeModule = ("default" in namespace ? namespace.default : namespace) as RuntimeModule;
|
||||
if (!runtimeModule || typeof runtimeModule.mount !== "function") return null;
|
||||
|
||||
return {
|
||||
html: await templatePromise,
|
||||
module: runtimeModule,
|
||||
};
|
||||
})();
|
||||
|
||||
runtimeAssetCache.set(templateKey, loader);
|
||||
return loader;
|
||||
}
|
||||
98
frontend/src/cardRuntime/runtimeHost.ts
Normal file
98
frontend/src/cardRuntime/runtimeHost.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { marked } from "marked";
|
||||
import { useRef } from "preact/hooks";
|
||||
import type { JsonValue } from "../types";
|
||||
import {
|
||||
callManualTool,
|
||||
callManualToolAsync,
|
||||
copyTextToClipboard,
|
||||
getManualToolJob,
|
||||
listManualTools,
|
||||
startManualToolCall,
|
||||
updateCardTemplateState,
|
||||
updateWorkbenchTemplateState,
|
||||
} from "./api";
|
||||
import type { RuntimeHost, RuntimeItem, RuntimeSurface } from "./runtimeTypes";
|
||||
import { normalizeTemplateState, runtimeItemId } from "./runtimeUtils";
|
||||
import {
|
||||
clearCardSelection,
|
||||
getCardLiveContent,
|
||||
getCardSelection,
|
||||
requestCardFeedRefresh,
|
||||
runCardRefresh,
|
||||
setCardLiveContent,
|
||||
setCardRefreshHandler,
|
||||
setCardSelection,
|
||||
} from "./store";
|
||||
|
||||
export function useRuntimeHost(
|
||||
surface: RuntimeSurface,
|
||||
item: RuntimeItem,
|
||||
stateRef: { current: Record<string, JsonValue> },
|
||||
setState: (value: Record<string, JsonValue>) => void,
|
||||
) {
|
||||
const itemRef = useRef(item);
|
||||
itemRef.current = item;
|
||||
|
||||
const hostRef = useRef<RuntimeHost | null>(null);
|
||||
if (!hostRef.current) {
|
||||
hostRef.current = {
|
||||
surface,
|
||||
item,
|
||||
getState: () => normalizeTemplateState(stateRef.current),
|
||||
replaceState: async (nextState) => {
|
||||
const normalized = normalizeTemplateState(nextState);
|
||||
stateRef.current = normalized;
|
||||
setState(normalized);
|
||||
const currentItem = itemRef.current;
|
||||
if ("serverId" in currentItem) {
|
||||
if (!currentItem.serverId) throw new Error("card id is required");
|
||||
await updateCardTemplateState(currentItem.serverId, normalized);
|
||||
} else if ("chatId" in currentItem) {
|
||||
await updateWorkbenchTemplateState(currentItem, normalized);
|
||||
}
|
||||
return normalizeTemplateState(normalized);
|
||||
},
|
||||
patchState: async (patch) => {
|
||||
const nextState = { ...stateRef.current, ...normalizeTemplateState(patch) };
|
||||
return hostRef.current?.replaceState(nextState) ?? normalizeTemplateState(nextState);
|
||||
},
|
||||
setLiveContent: (snapshot) => {
|
||||
setCardLiveContent(runtimeItemId(itemRef.current), snapshot);
|
||||
},
|
||||
getLiveContent: () => getCardLiveContent(runtimeItemId(itemRef.current)),
|
||||
setSelection: (selection) => {
|
||||
setCardSelection(runtimeItemId(itemRef.current), selection);
|
||||
},
|
||||
getSelection: () => getCardSelection(runtimeItemId(itemRef.current)),
|
||||
clearSelection: () => {
|
||||
clearCardSelection(runtimeItemId(itemRef.current));
|
||||
},
|
||||
setRefreshHandler: (handler) => {
|
||||
setCardRefreshHandler(runtimeItemId(itemRef.current), handler);
|
||||
},
|
||||
runRefresh: () => runCardRefresh(runtimeItemId(itemRef.current)),
|
||||
requestFeedRefresh: () => {
|
||||
requestCardFeedRefresh();
|
||||
},
|
||||
callTool: callManualTool,
|
||||
startToolCall: startManualToolCall,
|
||||
getToolJob: getManualToolJob,
|
||||
callToolAsync: callManualToolAsync,
|
||||
listTools: listManualTools,
|
||||
renderMarkdown: (markdown, options = {}) =>
|
||||
options.inline
|
||||
? (marked.parseInline(markdown) as string)
|
||||
: (marked.parse(markdown) as string),
|
||||
copyText: copyTextToClipboard,
|
||||
getThemeName: () => document.documentElement.dataset.theme || "clay",
|
||||
getThemeValue: (tokenName) => {
|
||||
const normalized = tokenName.startsWith("--") ? tokenName : `--${tokenName}`;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(normalized).trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
hostRef.current.surface = surface;
|
||||
hostRef.current.item = item;
|
||||
return hostRef.current;
|
||||
}
|
||||
58
frontend/src/cardRuntime/runtimeTypes.ts
Normal file
58
frontend/src/cardRuntime/runtimeTypes.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { CardItem, JsonValue, WorkbenchItem } from "../types";
|
||||
import type {
|
||||
ManualToolAsyncOptions,
|
||||
ManualToolDefinition,
|
||||
ManualToolJob,
|
||||
ManualToolResult,
|
||||
} from "./api";
|
||||
|
||||
export type RuntimeSurface = "feed" | "workbench";
|
||||
export type RuntimeItem = CardItem | WorkbenchItem;
|
||||
|
||||
export interface RuntimeHost {
|
||||
surface: RuntimeSurface;
|
||||
item: RuntimeItem;
|
||||
getState(): Record<string, JsonValue>;
|
||||
replaceState(nextState: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
|
||||
patchState(patch: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
|
||||
setLiveContent(snapshot: JsonValue | null | undefined): void;
|
||||
getLiveContent(): JsonValue | undefined;
|
||||
setSelection(selection: JsonValue | null | undefined): void;
|
||||
getSelection(): JsonValue | undefined;
|
||||
clearSelection(): void;
|
||||
setRefreshHandler(handler: (() => void) | null | undefined): void;
|
||||
runRefresh(): boolean;
|
||||
requestFeedRefresh(): void;
|
||||
callTool(toolName: string, argumentsValue?: Record<string, JsonValue>): Promise<ManualToolResult>;
|
||||
startToolCall(
|
||||
toolName: string,
|
||||
argumentsValue?: Record<string, JsonValue>,
|
||||
): Promise<ManualToolJob>;
|
||||
getToolJob(jobId: string): Promise<ManualToolJob>;
|
||||
callToolAsync(
|
||||
toolName: string,
|
||||
argumentsValue?: Record<string, JsonValue>,
|
||||
options?: ManualToolAsyncOptions,
|
||||
): Promise<ManualToolResult>;
|
||||
listTools(): Promise<ManualToolDefinition[]>;
|
||||
renderMarkdown(markdown: string, options?: { inline?: boolean }): string;
|
||||
copyText(text: string): Promise<void>;
|
||||
getThemeName(): string;
|
||||
getThemeValue(tokenName: string): string;
|
||||
}
|
||||
|
||||
export interface RuntimeContext {
|
||||
root: HTMLElement;
|
||||
item: RuntimeItem;
|
||||
state: Record<string, JsonValue>;
|
||||
host: RuntimeHost;
|
||||
}
|
||||
|
||||
export interface MountedRuntimeCard {
|
||||
update?(context: RuntimeContext): void;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
export interface RuntimeModule {
|
||||
mount(context: RuntimeContext): MountedRuntimeCard | undefined;
|
||||
}
|
||||
35
frontend/src/cardRuntime/runtimeUtils.ts
Normal file
35
frontend/src/cardRuntime/runtimeUtils.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { CardItem, JsonValue } from "../types";
|
||||
import type { RuntimeItem } from "./runtimeTypes";
|
||||
|
||||
export function cloneJsonValue<T extends JsonValue>(value: T | null | undefined): T | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
} catch {
|
||||
return value ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTemplateState(
|
||||
value: Record<string, JsonValue> | undefined,
|
||||
): Record<string, JsonValue> {
|
||||
const cloned = cloneJsonValue((value || {}) as JsonValue);
|
||||
return cloned && typeof cloned === "object" && !Array.isArray(cloned)
|
||||
? (cloned as Record<string, JsonValue>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function runtimeItemId(item: RuntimeItem): string {
|
||||
if (isCardItem(item)) {
|
||||
return `card:${item.serverId || item.id}`;
|
||||
}
|
||||
return `workbench:${item.chatId}:${item.id}`;
|
||||
}
|
||||
|
||||
function isCardItem(item: RuntimeItem): item is CardItem {
|
||||
return "lane" in item;
|
||||
}
|
||||
|
||||
export function looksLikeHtml(content: string): boolean {
|
||||
return /^\s*<[a-zA-Z]/.test(content);
|
||||
}
|
||||
192
frontend/src/cardRuntime/store.ts
Normal file
192
frontend/src/cardRuntime/store.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type { JsonValue } from "../types";
|
||||
|
||||
type LiveContentListener = (cardId: string, snapshot: JsonValue | undefined) => void;
|
||||
type SelectionListener = (cardId: string, selection: JsonValue | undefined) => void;
|
||||
|
||||
class CardRuntimeRegistry {
|
||||
private readonly liveContentStore = new Map<string, JsonValue>();
|
||||
private readonly selectionStore = new Map<string, JsonValue>();
|
||||
private readonly refreshHandlers = new Map<string, () => void>();
|
||||
private readonly liveContentListeners = new Set<LiveContentListener>();
|
||||
private readonly selectionListeners = new Set<SelectionListener>();
|
||||
|
||||
private emitLiveContent(cardId: string, snapshot: JsonValue | undefined): void {
|
||||
for (const listener of this.liveContentListeners) listener(cardId, cloneJsonValue(snapshot));
|
||||
}
|
||||
|
||||
private emitSelection(cardId: string, selection: JsonValue | undefined): void {
|
||||
for (const listener of this.selectionListeners) listener(cardId, cloneJsonValue(selection));
|
||||
}
|
||||
|
||||
setCardLiveContent(
|
||||
cardId: string | null | undefined,
|
||||
snapshot: JsonValue | null | undefined,
|
||||
): void {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return;
|
||||
const cloned = cloneJsonValue(snapshot ?? undefined);
|
||||
if (cloned === undefined) {
|
||||
this.liveContentStore.delete(key);
|
||||
this.emitLiveContent(key, undefined);
|
||||
return;
|
||||
}
|
||||
this.liveContentStore.set(key, cloned);
|
||||
this.emitLiveContent(key, cloned);
|
||||
}
|
||||
|
||||
getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return undefined;
|
||||
return cloneJsonValue(this.liveContentStore.get(key));
|
||||
}
|
||||
|
||||
subscribeCardLiveContent(listener: LiveContentListener): () => void {
|
||||
this.liveContentListeners.add(listener);
|
||||
return () => {
|
||||
this.liveContentListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
setCardSelection(
|
||||
cardId: string | null | undefined,
|
||||
selection: JsonValue | null | undefined,
|
||||
): void {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return;
|
||||
const cloned = cloneJsonValue(selection ?? undefined);
|
||||
if (cloned === undefined) {
|
||||
this.selectionStore.delete(key);
|
||||
this.emitSelection(key, undefined);
|
||||
return;
|
||||
}
|
||||
this.selectionStore.set(key, cloned);
|
||||
this.emitSelection(key, cloned);
|
||||
}
|
||||
|
||||
getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return undefined;
|
||||
return cloneJsonValue(this.selectionStore.get(key));
|
||||
}
|
||||
|
||||
clearCardSelection(cardId: string | null | undefined): void {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return;
|
||||
this.selectionStore.delete(key);
|
||||
this.emitSelection(key, undefined);
|
||||
}
|
||||
|
||||
subscribeCardSelection(listener: SelectionListener): () => void {
|
||||
this.selectionListeners.add(listener);
|
||||
return () => {
|
||||
this.selectionListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
setCardRefreshHandler(
|
||||
cardId: string | null | undefined,
|
||||
handler: (() => void) | null | undefined,
|
||||
): void {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return;
|
||||
if (typeof handler !== "function") {
|
||||
this.refreshHandlers.delete(key);
|
||||
return;
|
||||
}
|
||||
this.refreshHandlers.set(key, handler);
|
||||
}
|
||||
|
||||
hasCardRefreshHandler(cardId: string | null | undefined): boolean {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return false;
|
||||
return this.refreshHandlers.has(key);
|
||||
}
|
||||
|
||||
runCardRefresh(cardId: string | null | undefined): boolean {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return false;
|
||||
const handler = this.refreshHandlers.get(key);
|
||||
if (!handler) return false;
|
||||
handler();
|
||||
return true;
|
||||
}
|
||||
|
||||
disposeCardRuntimeEntry(cardId: string | null | undefined): void {
|
||||
const key = String(cardId || "").trim();
|
||||
if (!key) return;
|
||||
this.liveContentStore.delete(key);
|
||||
this.selectionStore.delete(key);
|
||||
this.refreshHandlers.delete(key);
|
||||
this.emitLiveContent(key, undefined);
|
||||
this.emitSelection(key, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new CardRuntimeRegistry();
|
||||
|
||||
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCardLiveContent(
|
||||
cardId: string | null | undefined,
|
||||
snapshot: JsonValue | null | undefined,
|
||||
): void {
|
||||
registry.setCardLiveContent(cardId, snapshot);
|
||||
}
|
||||
|
||||
export function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
|
||||
return registry.getCardLiveContent(cardId);
|
||||
}
|
||||
|
||||
export function subscribeCardLiveContent(listener: LiveContentListener): () => void {
|
||||
return registry.subscribeCardLiveContent(listener);
|
||||
}
|
||||
|
||||
export function setCardSelection(
|
||||
cardId: string | null | undefined,
|
||||
selection: JsonValue | null | undefined,
|
||||
): void {
|
||||
registry.setCardSelection(cardId, selection);
|
||||
}
|
||||
|
||||
export function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
|
||||
return registry.getCardSelection(cardId);
|
||||
}
|
||||
|
||||
export function clearCardSelection(cardId: string | null | undefined): void {
|
||||
registry.clearCardSelection(cardId);
|
||||
}
|
||||
|
||||
export function subscribeCardSelection(listener: SelectionListener): () => void {
|
||||
return registry.subscribeCardSelection(listener);
|
||||
}
|
||||
|
||||
export function setCardRefreshHandler(
|
||||
cardId: string | null | undefined,
|
||||
handler: (() => void) | null | undefined,
|
||||
): void {
|
||||
registry.setCardRefreshHandler(cardId, handler);
|
||||
}
|
||||
|
||||
export function hasCardRefreshHandler(cardId: string | null | undefined): boolean {
|
||||
return registry.hasCardRefreshHandler(cardId);
|
||||
}
|
||||
|
||||
export function runCardRefresh(cardId: string | null | undefined): boolean {
|
||||
return registry.runCardRefresh(cardId);
|
||||
}
|
||||
|
||||
export function disposeCardRuntimeEntry(cardId: string | null | undefined): void {
|
||||
registry.disposeCardRuntimeEntry(cardId);
|
||||
}
|
||||
|
||||
export function requestCardFeedRefresh(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event("nanobot:cards-refresh"));
|
||||
}
|
||||
124
frontend/src/components/CardBodyRenderer.tsx
Normal file
124
frontend/src/components/CardBodyRenderer.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { marked } from "marked";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import {
|
||||
loadRuntimeAssets,
|
||||
materializeRuntimeHtml,
|
||||
syncRuntimeStateScript,
|
||||
} from "../cardRuntime/runtimeAssets";
|
||||
import { useRuntimeHost } from "../cardRuntime/runtimeHost";
|
||||
import type { MountedRuntimeCard, RuntimeItem, RuntimeSurface } from "../cardRuntime/runtimeTypes";
|
||||
import { looksLikeHtml, normalizeTemplateState, runtimeItemId } from "../cardRuntime/runtimeUtils";
|
||||
import { disposeCardRuntimeEntry } from "../cardRuntime/store";
|
||||
import type { JsonValue } from "../types";
|
||||
|
||||
function StaticCardTextBody({
|
||||
content,
|
||||
bodyClass = "card-body",
|
||||
}: {
|
||||
content: string;
|
||||
bodyClass?: string;
|
||||
}) {
|
||||
const html = looksLikeHtml(content) ? content : (marked.parse(content) as string);
|
||||
return <div class={bodyClass} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
export function DynamicCardBody({
|
||||
item,
|
||||
surface,
|
||||
bodyClass = "card-body",
|
||||
}: {
|
||||
item: RuntimeItem;
|
||||
surface: RuntimeSurface;
|
||||
bodyClass?: string;
|
||||
}) {
|
||||
const templateKey = item.templateKey?.trim() || "";
|
||||
const identity = runtimeItemId(item);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef<MountedRuntimeCard | null>(null);
|
||||
const [runtimeAvailable, setRuntimeAvailable] = useState<boolean | null>(
|
||||
templateKey ? null : false,
|
||||
);
|
||||
const [runtimeState, setRuntimeState] = useState<Record<string, JsonValue>>(
|
||||
normalizeTemplateState(item.templateState),
|
||||
);
|
||||
const runtimeStateRef = useRef(runtimeState);
|
||||
runtimeStateRef.current = runtimeState;
|
||||
const host = useRuntimeHost(surface, item, runtimeStateRef, setRuntimeState);
|
||||
|
||||
useEffect(() => {
|
||||
const nextState = normalizeTemplateState(item.templateState);
|
||||
const activeElement = document.activeElement;
|
||||
const editingInside =
|
||||
activeElement instanceof Node && !!rootRef.current?.contains(activeElement);
|
||||
if (editingInside) return;
|
||||
const currentJson = JSON.stringify(runtimeStateRef.current);
|
||||
const nextJson = JSON.stringify(nextState);
|
||||
if (currentJson === nextJson) return;
|
||||
runtimeStateRef.current = nextState;
|
||||
setRuntimeState(nextState);
|
||||
}, [item.updatedAt, item.templateState]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const mountRuntime = async () => {
|
||||
if (!templateKey) {
|
||||
setRuntimeAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeAvailable(null);
|
||||
const assets = await loadRuntimeAssets(templateKey);
|
||||
if (cancelled) return;
|
||||
if (!assets || !rootRef.current) {
|
||||
setRuntimeAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mountedRef.current?.destroy?.();
|
||||
rootRef.current.innerHTML = materializeRuntimeHtml(
|
||||
assets.html || "",
|
||||
item,
|
||||
runtimeStateRef.current,
|
||||
);
|
||||
mountedRef.current =
|
||||
assets.module.mount({
|
||||
root: rootRef.current,
|
||||
item,
|
||||
state: runtimeStateRef.current,
|
||||
host,
|
||||
}) || null;
|
||||
setRuntimeAvailable(true);
|
||||
};
|
||||
|
||||
void mountRuntime();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current?.destroy?.();
|
||||
mountedRef.current = null;
|
||||
host.setRefreshHandler(null);
|
||||
host.setLiveContent(undefined);
|
||||
host.clearSelection();
|
||||
disposeCardRuntimeEntry(identity);
|
||||
};
|
||||
}, [host, identity, templateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeAvailable || !rootRef.current) return;
|
||||
syncRuntimeStateScript(rootRef.current, runtimeState);
|
||||
if (!mountedRef.current?.update) return;
|
||||
mountedRef.current.update({
|
||||
root: rootRef.current,
|
||||
item,
|
||||
state: runtimeState,
|
||||
host,
|
||||
});
|
||||
}, [host, item, runtimeAvailable, runtimeState]);
|
||||
|
||||
if (!templateKey || runtimeAvailable === false) {
|
||||
return <StaticCardTextBody content={item.content} bodyClass={bodyClass} />;
|
||||
}
|
||||
|
||||
return <div ref={rootRef} class={bodyClass} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,28 +3,6 @@ interface VoiceStatusProps {
|
|||
visible: boolean;
|
||||
}
|
||||
|
||||
function SpeakerIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
|
||||
<path
|
||||
d="M17 9.5a4 4 0 0 1 0 5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
<path
|
||||
d="M18.8 7a7 7 0 0 1 0 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBubbleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
|
|
@ -38,28 +16,6 @@ function TextBubbleIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function ResetIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M6.5 8A7 7 0 1 1 5 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.9"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 4.5V8H10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceStatus({ text, visible }: VoiceStatusProps) {
|
||||
return (
|
||||
<div id="voiceStatus" class={visible ? "visible" : ""}>
|
||||
|
|
@ -69,54 +25,37 @@ export function VoiceStatus({ text, visible }: VoiceStatusProps) {
|
|||
}
|
||||
|
||||
interface ControlBarProps {
|
||||
onReset(): void;
|
||||
textOnly: boolean;
|
||||
onToggleTextOnly(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function ControlBar({ onReset, textOnly, onToggleTextOnly }: ControlBarProps) {
|
||||
const toggleLabel = textOnly ? "Text-only mode on" : "Voice mode on";
|
||||
export function ControlBar({ textOnly, onToggleTextOnly }: ControlBarProps) {
|
||||
const voiceActive = !textOnly;
|
||||
const toggleLabel = voiceActive ? "Exit voice mode" : "Open voice mode";
|
||||
|
||||
return (
|
||||
<div id="controls">
|
||||
<button
|
||||
id="textOnlyToggleBtn"
|
||||
class={`control-switch${textOnly ? " active" : ""}`}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={textOnly}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleTextOnly(!textOnly);
|
||||
}}
|
||||
>
|
||||
<span class="control-switch-shell" aria-hidden="true">
|
||||
<span class="control-switch-icon control-switch-icon-speaker">
|
||||
<SpeakerIcon />
|
||||
</span>
|
||||
<span class="control-switch-icon control-switch-icon-text">
|
||||
<TextBubbleIcon />
|
||||
</span>
|
||||
<span class="control-switch-thumb" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
id="resetSessionBtn"
|
||||
class="control-icon-btn"
|
||||
type="button"
|
||||
aria-label="Reset context"
|
||||
title="Reset context"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
>
|
||||
<ResetIcon />
|
||||
</button>
|
||||
<div class="control-group">
|
||||
{voiceActive ? (
|
||||
<button
|
||||
id="voiceModeBtn"
|
||||
class="control-mode-btn active"
|
||||
type="button"
|
||||
aria-pressed={voiceActive}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleTextOnly(!textOnly);
|
||||
}}
|
||||
>
|
||||
<span class="control-mode-btn-icon" aria-hidden="true">
|
||||
<TextBubbleIcon />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { LogLine } from "../types";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { AgentState, LogLine } from "../types";
|
||||
|
||||
interface Props {
|
||||
lines: LogLine[];
|
||||
disabled: boolean;
|
||||
onSendMessage(text: string): Promise<void>;
|
||||
onOpenVoiceMode?(): Promise<void> | void;
|
||||
onExpandChange?(expanded: boolean): void;
|
||||
contextLabel?: string | null;
|
||||
onClearContext?(): void;
|
||||
agentState: AgentState;
|
||||
textStreaming: boolean;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
interface LogViewProps {
|
||||
|
|
@ -13,6 +19,14 @@ interface LogViewProps {
|
|||
scrollRef: { current: HTMLElement | null };
|
||||
}
|
||||
|
||||
type ChatRole = "user" | "assistant" | "system" | "tool";
|
||||
|
||||
interface ActivityStatus {
|
||||
tone: "waiting" | "thinking" | "tool" | "replying";
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function SendIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
|
|
@ -21,6 +35,28 @@ function SendIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function SpeakerIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
|
||||
<path
|
||||
d="M17 9.5a4 4 0 0 1 0 5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
<path
|
||||
d="M18.8 7a7 7 0 0 1 0 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
|
|
@ -30,15 +66,72 @@ function CloseIcon() {
|
|||
}
|
||||
|
||||
function formatLine(line: LogLine): string {
|
||||
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
|
||||
const role = line.role.trim().toLowerCase();
|
||||
if (role === "nanobot") {
|
||||
return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
|
||||
if (role === "nanobot" || role === "nanobot-progress") {
|
||||
return line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "");
|
||||
}
|
||||
if (role === "user") {
|
||||
return line.text;
|
||||
}
|
||||
if (role === "system" || role === "wisper") {
|
||||
return line.text;
|
||||
}
|
||||
if (role === "tool") {
|
||||
return `[${time}] tool: ${line.text}`;
|
||||
return `tool: ${line.text}`;
|
||||
}
|
||||
return `[${time}] ${line.role}: ${line.text}`;
|
||||
return `${line.role}: ${line.text}`;
|
||||
}
|
||||
|
||||
function toChatRole(line: LogLine): ChatRole {
|
||||
const role = line.role.trim().toLowerCase();
|
||||
if (role === "user") return "user";
|
||||
if (role === "nanobot" || role === "nanobot-progress") return "assistant";
|
||||
if (role === "tool") return "tool";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function isEphemeralLine(line: LogLine): boolean {
|
||||
const role = line.role.trim().toLowerCase();
|
||||
if (role !== "system") return false;
|
||||
return /^(connected|disconnected)\.?$/i.test(line.text.trim());
|
||||
}
|
||||
|
||||
function deriveActivityStatus(
|
||||
lines: LogLine[],
|
||||
agentState: AgentState,
|
||||
textStreaming: boolean,
|
||||
sending: boolean,
|
||||
): ActivityStatus | null {
|
||||
if (!textStreaming && !sending && agentState === "idle") return null;
|
||||
|
||||
const latestUserId = [...lines].reverse().find((line) => line.role === "user")?.id ?? -1;
|
||||
const latestAssistantProgress = [...lines]
|
||||
.reverse()
|
||||
.find((line) => line.id > latestUserId && line.role === "nanobot-progress");
|
||||
const latestTool = [...lines]
|
||||
.reverse()
|
||||
.find((line) => line.id > latestUserId && line.role === "tool");
|
||||
|
||||
if (latestAssistantProgress) {
|
||||
return { tone: "replying", label: "Nanobot is replying" };
|
||||
}
|
||||
if (latestTool && (textStreaming || sending)) {
|
||||
return {
|
||||
tone: "tool",
|
||||
label: "Nanobot is using a tool",
|
||||
detail: latestTool.text.trim() || undefined,
|
||||
};
|
||||
}
|
||||
if (agentState === "thinking") {
|
||||
return { tone: "thinking", label: "Nanobot is thinking" };
|
||||
}
|
||||
if (agentState === "speaking") {
|
||||
return { tone: "replying", label: "Nanobot is replying" };
|
||||
}
|
||||
if (textStreaming || sending) {
|
||||
return { tone: "waiting", label: "Waiting for Nanobot" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function LogCompose({
|
||||
|
|
@ -48,6 +141,11 @@ function LogCompose({
|
|||
setText,
|
||||
onClose,
|
||||
onSend,
|
||||
onOpenVoiceMode,
|
||||
showClose,
|
||||
contextLabel,
|
||||
onClearContext,
|
||||
activityStatus,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
sending: boolean;
|
||||
|
|
@ -55,6 +153,11 @@ function LogCompose({
|
|||
setText(value: string): void;
|
||||
onClose(): void;
|
||||
onSend(): void;
|
||||
onOpenVoiceMode?: () => void;
|
||||
showClose: boolean;
|
||||
contextLabel?: string | null;
|
||||
onClearContext?: () => void;
|
||||
activityStatus?: ActivityStatus | null;
|
||||
}) {
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
|
|
@ -72,27 +175,69 @@ function LogCompose({
|
|||
|
||||
return (
|
||||
<div id="log-compose">
|
||||
<textarea
|
||||
id="log-compose-input"
|
||||
placeholder="Type a message to nanobot..."
|
||||
disabled={disabled || sending}
|
||||
value={text}
|
||||
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div id="log-compose-actions">
|
||||
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<button
|
||||
id="log-send-btn"
|
||||
type="button"
|
||||
aria-label="Send message"
|
||||
disabled={disabled || sending || text.trim().length === 0}
|
||||
onClick={onSend}
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
{showClose ? (
|
||||
<div id="log-compose-toolbar">
|
||||
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div id="log-compose-field">
|
||||
{activityStatus ? (
|
||||
<div id="log-compose-status" data-tone={activityStatus.tone} aria-live="polite">
|
||||
<span id="log-compose-status-dots" aria-hidden="true">
|
||||
<span class="log-compose-status-dot" />
|
||||
<span class="log-compose-status-dot" />
|
||||
<span class="log-compose-status-dot" />
|
||||
</span>
|
||||
<span id="log-compose-status-label">{activityStatus.label}</span>
|
||||
{activityStatus.detail ? (
|
||||
<span id="log-compose-status-detail">{activityStatus.detail}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{contextLabel ? (
|
||||
<div id="log-compose-context">
|
||||
<button
|
||||
id="log-compose-context-chip"
|
||||
type="button"
|
||||
onClick={() => onClearContext?.()}
|
||||
disabled={disabled || sending}
|
||||
>
|
||||
[{contextLabel}]
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
id="log-compose-input"
|
||||
placeholder="Type a message to nanobot..."
|
||||
disabled={disabled || sending}
|
||||
value={text}
|
||||
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div id="log-compose-actions">
|
||||
{onOpenVoiceMode ? (
|
||||
<button
|
||||
id="log-voice-btn"
|
||||
type="button"
|
||||
aria-label="Open voice mode"
|
||||
disabled={disabled || sending}
|
||||
onClick={onOpenVoiceMode}
|
||||
>
|
||||
<SpeakerIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
id="log-send-btn"
|
||||
type="button"
|
||||
aria-label="Send message"
|
||||
disabled={disabled || sending || text.trim().length === 0}
|
||||
onClick={onSend}
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -117,6 +262,45 @@ function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChatLogView({ lines, scrollRef }: LogViewProps) {
|
||||
const visibleLines = lines.filter((line) => !isEphemeralLine(line));
|
||||
|
||||
return (
|
||||
<div
|
||||
id="chat-scroll"
|
||||
ref={(node) => {
|
||||
scrollRef.current = node;
|
||||
}}
|
||||
>
|
||||
{visibleLines.length === 0 ? (
|
||||
<div id="chat-empty">
|
||||
<div id="chat-empty-eyebrow">Nanobot</div>
|
||||
<h1 id="chat-empty-title">Start a conversation</h1>
|
||||
<p id="chat-empty-copy">
|
||||
Ask a question, plan something, or switch to voice when you want the live interface.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div id="chat-inner">
|
||||
{visibleLines.map((line) => {
|
||||
const chatRole = toChatRole(line);
|
||||
return (
|
||||
<div key={line.id} class={`chat-row ${chatRole}`}>
|
||||
<div class={`chat-bubble ${chatRole}`}>
|
||||
{chatRole === "user" && line.contextLabel ? (
|
||||
<span class="chat-context-inline">[{line.contextLabel}] </span>
|
||||
) : null}
|
||||
{formatLine(line)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -139,23 +323,46 @@ function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExp
|
|||
);
|
||||
}
|
||||
|
||||
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
|
||||
export function LogPanel({
|
||||
lines,
|
||||
disabled,
|
||||
onSendMessage,
|
||||
onOpenVoiceMode,
|
||||
onExpandChange,
|
||||
contextLabel,
|
||||
onClearContext,
|
||||
agentState,
|
||||
textStreaming,
|
||||
fullScreen = false,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const isExpanded = fullScreen || expanded;
|
||||
const activityStatus = useMemo(
|
||||
() => (fullScreen ? deriveActivityStatus(lines, agentState, textStreaming, sending) : null),
|
||||
[agentState, fullScreen, lines, sending, textStreaming],
|
||||
);
|
||||
|
||||
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
|
||||
useEffect(
|
||||
() => onExpandChange?.(fullScreen ? false : isExpanded),
|
||||
[fullScreen, isExpanded, onExpandChange],
|
||||
);
|
||||
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
|
||||
useEffect(() => {
|
||||
if (fullScreen) setExpanded(true);
|
||||
}, [fullScreen]);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines, expanded]);
|
||||
}, [lines, isExpanded]);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
if (fullScreen) return;
|
||||
setExpanded(false);
|
||||
setText("");
|
||||
}, []);
|
||||
}, [fullScreen]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
if (!expanded) setExpanded(true);
|
||||
|
|
@ -176,13 +383,19 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
|
|||
}, [disabled, onSendMessage, sending, text]);
|
||||
|
||||
return (
|
||||
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
|
||||
{expanded ? (
|
||||
<div
|
||||
id="log"
|
||||
class={`${isExpanded ? "expanded" : ""}${fullScreen ? " chat-mode" : ""}`}
|
||||
data-no-swipe={fullScreen ? undefined : "1"}
|
||||
>
|
||||
{fullScreen ? (
|
||||
<ChatLogView lines={lines} scrollRef={scrollRef} />
|
||||
) : isExpanded ? (
|
||||
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
|
||||
) : (
|
||||
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
|
||||
)}
|
||||
{expanded && (
|
||||
{isExpanded && (
|
||||
<LogCompose
|
||||
disabled={disabled}
|
||||
sending={sending}
|
||||
|
|
@ -192,6 +405,11 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
|
|||
onSend={() => {
|
||||
void send();
|
||||
}}
|
||||
onOpenVoiceMode={fullScreen ? () => void onOpenVoiceMode?.() : undefined}
|
||||
showClose={!fullScreen}
|
||||
activityStatus={activityStatus}
|
||||
contextLabel={contextLabel}
|
||||
onClearContext={onClearContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
514
frontend/src/components/SessionDrawer.tsx
Normal file
514
frontend/src/components/SessionDrawer.tsx
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ThemeName, ThemeOption } from "../theme/themes";
|
||||
import type { SessionSummary } from "../types";
|
||||
|
||||
const DRAWER_SWIPE_INTENT_PX = 12;
|
||||
const DRAWER_SWIPE_CLOSE_THRESHOLD_PX = 52;
|
||||
const DRAWER_SWIPE_DIRECTION_RATIO = 1.15;
|
||||
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
|
||||
const SESSION_DRAWER_GUTTER_PX = 28;
|
||||
const SESSION_LONG_PRESS_MS = 460;
|
||||
const SESSION_LONG_PRESS_MOVE_PX = 14;
|
||||
|
||||
interface DrawerSwipeState {
|
||||
identifier: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6L18 18M18 6L6 18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUpdatedAt(timestamp: string): string {
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function findTouchById(touches: TouchList, identifier: number): Touch | null {
|
||||
for (let i = 0; i < touches.length; i += 1) {
|
||||
const touch = touches.item(i);
|
||||
if (touch && touch.identifier === identifier) return touch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSwipeIntent(dx: number, dy: number): boolean {
|
||||
return Math.abs(dx) >= DRAWER_SWIPE_INTENT_PX || Math.abs(dy) >= DRAWER_SWIPE_INTENT_PX;
|
||||
}
|
||||
|
||||
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
|
||||
return Math.abs(dx) < Math.abs(dy) * DRAWER_SWIPE_DIRECTION_RATIO;
|
||||
}
|
||||
|
||||
function getDrawerWidth(): number {
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 1;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
|
||||
);
|
||||
}
|
||||
|
||||
function useSessionDrawerCloseSwipe(open: boolean, onClose: () => void) {
|
||||
const [swipe, setSwipe] = useState<DrawerSwipeState | null>(null);
|
||||
const [closeProgress, setCloseProgress] = useState(0);
|
||||
|
||||
const clearSwipe = useCallback(() => {
|
||||
setSwipe(null);
|
||||
setCloseProgress(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) clearSwipe();
|
||||
}, [clearSwipe, open]);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: Event) => {
|
||||
if (!open) return;
|
||||
const te = e as TouchEvent;
|
||||
if (te.touches.length !== 1) return;
|
||||
const touch = te.touches.item(0);
|
||||
if (!touch) return;
|
||||
setSwipe({
|
||||
identifier: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
});
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: Event) => {
|
||||
if (!open || !swipe) return;
|
||||
const te = e as TouchEvent;
|
||||
const touch = findTouchById(te.touches, swipe.identifier);
|
||||
if (!touch) return;
|
||||
|
||||
const dx = swipe.x - touch.clientX;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!hasSwipeIntent(dx, dy)) return;
|
||||
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) {
|
||||
clearSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (te.cancelable) te.preventDefault();
|
||||
setCloseProgress(Math.max(0, Math.min(1, dx / getDrawerWidth())));
|
||||
},
|
||||
[clearSwipe, open, swipe],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: Event) => {
|
||||
if (!swipe) {
|
||||
clearSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const te = e as TouchEvent;
|
||||
const touch = findTouchById(te.changedTouches, swipe.identifier);
|
||||
if (!touch) {
|
||||
clearSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = swipe.x - touch.clientX;
|
||||
const shouldClose = dx >= DRAWER_SWIPE_CLOSE_THRESHOLD_PX;
|
||||
clearSwipe();
|
||||
if (shouldClose) onClose();
|
||||
},
|
||||
[clearSwipe, onClose, swipe],
|
||||
);
|
||||
|
||||
return {
|
||||
closeProgress,
|
||||
dragging: closeProgress > 0,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel: clearSwipe,
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionDrawerProps {
|
||||
open: boolean;
|
||||
progress: number;
|
||||
dragging: boolean;
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string;
|
||||
busy: boolean;
|
||||
onClose(): void;
|
||||
onCreate(): Promise<void> | void;
|
||||
onSelect(chatId: string): Promise<void> | void;
|
||||
onRename(chatId: string, title: string): Promise<void> | void;
|
||||
onDelete(chatId: string): Promise<void> | void;
|
||||
activeTheme: ThemeName;
|
||||
themeOptions: ThemeOption[];
|
||||
onSelectTheme(themeName: ThemeName): void;
|
||||
}
|
||||
|
||||
function useMetaOverflow() {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
setCompact((current) => {
|
||||
const tooWide = node.scrollWidth > node.clientWidth + 1;
|
||||
if (!current) return tooWide;
|
||||
|
||||
// Hysteresis: once compact, require real spare room before switching back.
|
||||
const hasSpareRoom = node.scrollWidth <= node.clientWidth - 12;
|
||||
return !hasSpareRoom;
|
||||
});
|
||||
};
|
||||
const scheduleMeasure = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
scheduleMeasure();
|
||||
const observer = new ResizeObserver(scheduleMeasure);
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ref, compact };
|
||||
}
|
||||
|
||||
function SessionDrawerItem({
|
||||
session,
|
||||
active,
|
||||
busy,
|
||||
onSelect,
|
||||
onOpenMenu,
|
||||
}: {
|
||||
session: SessionSummary;
|
||||
active: boolean;
|
||||
busy: boolean;
|
||||
onSelect(chatId: string): Promise<void> | void;
|
||||
onOpenMenu(session: SessionSummary): void;
|
||||
}) {
|
||||
const meta = useMetaOverflow();
|
||||
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const longPressTriggeredRef = useRef(false);
|
||||
|
||||
const clearHoldTimer = useCallback(() => {
|
||||
if (holdTimerRef.current) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
holdStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleLongPress = useCallback(
|
||||
(x: number, y: number) => {
|
||||
if (busy) return;
|
||||
longPressTriggeredRef.current = false;
|
||||
holdStartRef.current = { x, y };
|
||||
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
longPressTriggeredRef.current = true;
|
||||
onOpenMenu(session);
|
||||
}, SESSION_LONG_PRESS_MS);
|
||||
},
|
||||
[busy, onOpenMenu, session],
|
||||
);
|
||||
|
||||
useEffect(() => clearHoldTimer, [clearHoldTimer]);
|
||||
|
||||
return (
|
||||
<button
|
||||
class={`session-drawer-item${active ? " active" : ""}`}
|
||||
type="button"
|
||||
aria-disabled={busy ? "true" : undefined}
|
||||
onClick={(e) => {
|
||||
if (longPressTriggeredRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
longPressTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (busy || active) return;
|
||||
void onSelect(session.chat_id);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
longPressTriggeredRef.current = true;
|
||||
onOpenMenu(session);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const touch = e.touches.item(0);
|
||||
if (!touch) return;
|
||||
scheduleLongPress(touch.clientX, touch.clientY);
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const touch = e.touches.item(0);
|
||||
const start = holdStartRef.current;
|
||||
if (!touch || !start) return;
|
||||
const dx = touch.clientX - start.x;
|
||||
const dy = touch.clientY - start.y;
|
||||
if (Math.hypot(dx, dy) > SESSION_LONG_PRESS_MOVE_PX) clearHoldTimer();
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
clearHoldTimer();
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
clearHoldTimer();
|
||||
}}
|
||||
>
|
||||
<div class="session-drawer-item-title">{session.title || "New conversation"}</div>
|
||||
{session.preview ? <div class="session-drawer-item-preview">{session.preview}</div> : null}
|
||||
<div ref={meta.ref} class={`session-drawer-item-meta${meta.compact ? " compact" : ""}`}>
|
||||
<span>{formatUpdatedAt(session.updated_at)}</span>
|
||||
<span>{session.message_count} msgs</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionActionMenu({
|
||||
session,
|
||||
busy,
|
||||
onClose,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
session: SessionSummary;
|
||||
busy: boolean;
|
||||
onClose(): void;
|
||||
onRename(chatId: string, title: string): Promise<void> | void;
|
||||
onDelete(chatId: string): Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id="session-action-backdrop"
|
||||
type="button"
|
||||
aria-label="Close conversation actions"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div id="session-action-sheet" data-no-swipe="1">
|
||||
<div id="session-action-title">{session.title || "New conversation"}</div>
|
||||
<button
|
||||
class="session-action-btn"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const nextTitle = window.prompt("Rename conversation", session.title || "");
|
||||
if (nextTitle === null) return;
|
||||
void onRename(session.chat_id, nextTitle.trim());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="session-action-btn destructive"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const confirmed = window.confirm(`Delete "${session.title || "New conversation"}"?`);
|
||||
if (!confirmed) return;
|
||||
void onDelete(session.chat_id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button class="session-action-btn" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionThemePicker({
|
||||
activeTheme,
|
||||
themeOptions,
|
||||
onSelectTheme,
|
||||
}: {
|
||||
activeTheme: ThemeName;
|
||||
themeOptions: ThemeOption[];
|
||||
onSelectTheme(themeName: ThemeName): void;
|
||||
}) {
|
||||
return (
|
||||
<div id="session-drawer-theme">
|
||||
<div id="session-drawer-theme-label">Theme</div>
|
||||
<div id="session-drawer-theme-options">
|
||||
{themeOptions.map((theme) => (
|
||||
<button
|
||||
key={theme.name}
|
||||
type="button"
|
||||
class={`session-drawer-theme-option${theme.name === activeTheme ? " active" : ""}`}
|
||||
aria-pressed={theme.name === activeTheme}
|
||||
onClick={() => onSelectTheme(theme.name)}
|
||||
>
|
||||
<span
|
||||
class="session-drawer-theme-swatch"
|
||||
aria-hidden="true"
|
||||
style={{ background: theme.swatch }}
|
||||
/>
|
||||
<span>{theme.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionDrawer({
|
||||
open,
|
||||
progress,
|
||||
dragging,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
busy,
|
||||
onClose,
|
||||
onCreate,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
activeTheme,
|
||||
themeOptions,
|
||||
onSelectTheme,
|
||||
}: SessionDrawerProps) {
|
||||
const closeSwipe = useSessionDrawerCloseSwipe(open, onClose);
|
||||
const [actionSession, setActionSession] = useState<SessionSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setActionSession(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActionSession((current) => {
|
||||
if (!current) return current;
|
||||
return current.chat_id === activeSessionId ? current : null;
|
||||
});
|
||||
}, [activeSessionId]);
|
||||
|
||||
const visible = open || progress > 0 || closeSwipe.closeProgress > 0;
|
||||
if (!visible) return null;
|
||||
|
||||
const drawerProgress = open ? 1 - closeSwipe.closeProgress : progress;
|
||||
const inMotion = dragging || closeSwipe.dragging;
|
||||
const backdropStyle = {
|
||||
opacity: String(drawerProgress),
|
||||
transition: inMotion ? "none" : "opacity 0.2s ease",
|
||||
pointerEvents: open ? "auto" : "none",
|
||||
};
|
||||
const drawerStyle = {
|
||||
transform: `translateX(${(drawerProgress - 1) * 100}%)`,
|
||||
transition: inMotion ? "none" : "transform 0.2s ease",
|
||||
};
|
||||
const actionMenuOpen = actionSession !== null;
|
||||
|
||||
return (
|
||||
<div id="session-drawer-root" data-no-swipe="1">
|
||||
<button
|
||||
id="session-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="Close conversations"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
id="session-drawer"
|
||||
aria-label="Conversations"
|
||||
style={drawerStyle}
|
||||
onTouchStart={closeSwipe.onTouchStart}
|
||||
onTouchMove={closeSwipe.onTouchMove}
|
||||
onTouchEnd={closeSwipe.onTouchEnd}
|
||||
onTouchCancel={closeSwipe.onTouchCancel}
|
||||
>
|
||||
<div id="session-drawer-header">
|
||||
<div>
|
||||
<div id="session-drawer-eyebrow">Nanobot</div>
|
||||
<h2 id="session-drawer-title">Conversations</h2>
|
||||
</div>
|
||||
<button
|
||||
id="session-drawer-close"
|
||||
type="button"
|
||||
aria-label="Close conversations"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="session-drawer-new"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
void onCreate();
|
||||
}}
|
||||
>
|
||||
New conversation
|
||||
</button>
|
||||
|
||||
<SessionThemePicker
|
||||
activeTheme={activeTheme}
|
||||
themeOptions={themeOptions}
|
||||
onSelectTheme={onSelectTheme}
|
||||
/>
|
||||
|
||||
<div id="session-drawer-list">
|
||||
{sessions.map((session) => {
|
||||
const active = session.chat_id === activeSessionId;
|
||||
return (
|
||||
<SessionDrawerItem
|
||||
key={session.chat_id}
|
||||
session={session}
|
||||
active={active}
|
||||
busy={busy}
|
||||
onSelect={onSelect}
|
||||
onOpenMenu={setActionSession}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
{actionMenuOpen ? (
|
||||
<SessionActionMenu
|
||||
session={actionSession}
|
||||
busy={busy}
|
||||
onClose={() => setActionSession(null)}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/WorkbenchOverlay.tsx
Normal file
194
frontend/src/components/WorkbenchOverlay.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import type { WorkbenchItem } from "../types";
|
||||
import { DynamicCardBody } from "./CardBodyRenderer";
|
||||
|
||||
interface WorkbenchOverlayProps {
|
||||
items: WorkbenchItem[];
|
||||
onDismiss(id: string): Promise<void>;
|
||||
onPromote(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
function DismissIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="M14.5 5.5L5.5 14.5M5.5 5.5L14.5 14.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M10 4l4 4h-2.4v5H8.4V8H6l4-4Z" fill="currentColor" />
|
||||
<path
|
||||
d="M5.5 15.5h9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function workbenchTabLabel(item: WorkbenchItem): string {
|
||||
const title = item.title.trim();
|
||||
if (!title) return "Workbench";
|
||||
return title.length > 22 ? `${title.slice(0, 22).trimEnd()}...` : title;
|
||||
}
|
||||
|
||||
function WorkbenchQuestionBody({ item }: { item: WorkbenchItem }) {
|
||||
return (
|
||||
<div class="workbench-overlay-question">
|
||||
{item.content.trim() ? (
|
||||
<div class="workbench-overlay-question-copy">{item.content.trim()}</div>
|
||||
) : null}
|
||||
{item.question?.trim() ? (
|
||||
<div class="workbench-overlay-question-prompt">{item.question.trim()}</div>
|
||||
) : null}
|
||||
{item.choices?.length ? (
|
||||
<div class="workbench-overlay-question-choices">
|
||||
{item.choices.map((choice) => (
|
||||
<span key={choice} class="workbench-overlay-question-choice">
|
||||
{choice}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this overlay owns item selection and async promote/dismiss controls together
|
||||
export function WorkbenchOverlay({ items, onDismiss, onPromote }: WorkbenchOverlayProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
if (activeId && items.some((item) => item.id === activeId)) return;
|
||||
setActiveId(items[0].id);
|
||||
}, [activeId, items]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() =>
|
||||
activeId
|
||||
? (items.find((item) => item.id === activeId) ?? items[0] ?? null)
|
||||
: (items[0] ?? null),
|
||||
[activeId, items],
|
||||
);
|
||||
|
||||
if (!activeItem) return null;
|
||||
|
||||
const busy = busyId === activeItem.id;
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (!activeItem || busy) return;
|
||||
setBusyId(activeItem.id);
|
||||
setErrorText("");
|
||||
try {
|
||||
await onDismiss(activeItem.id);
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromote = async () => {
|
||||
if (!activeItem || busy || !activeItem.promotable) return;
|
||||
setBusyId(activeItem.id);
|
||||
setErrorText("");
|
||||
try {
|
||||
await onPromote(activeItem.id);
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="workbench-overlay-root" data-no-swipe="1">
|
||||
<section id="workbench-overlay" aria-label="Workbench">
|
||||
<div id="workbench-overlay-head">
|
||||
<div id="workbench-overlay-eyebrow">Workbench</div>
|
||||
<div id="workbench-overlay-actions">
|
||||
{activeItem.promotable ? (
|
||||
<button
|
||||
class="workbench-overlay-icon-btn"
|
||||
type="button"
|
||||
aria-label="Add this workbench item to the feed"
|
||||
title="Add to feed"
|
||||
disabled={busy}
|
||||
onClick={handlePromote}
|
||||
>
|
||||
<PromoteIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
class="workbench-overlay-icon-btn"
|
||||
type="button"
|
||||
aria-label="Dismiss this workbench item"
|
||||
title="Dismiss"
|
||||
disabled={busy}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<DismissIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{items.length > 1 ? (
|
||||
<div id="workbench-overlay-tabs">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
class={`workbench-overlay-tab${item.id === activeItem.id ? " active" : ""}`}
|
||||
type="button"
|
||||
disabled={busyId !== null && busyId !== item.id}
|
||||
onClick={() => {
|
||||
setActiveId(item.id);
|
||||
setErrorText("");
|
||||
}}
|
||||
>
|
||||
{workbenchTabLabel(item)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div id="workbench-overlay-title">{activeItem.title || "Untitled workbench"}</div>
|
||||
{activeItem.contextSummary?.trim() ? (
|
||||
<div id="workbench-overlay-blurb">{activeItem.contextSummary.trim()}</div>
|
||||
) : null}
|
||||
<div id="workbench-overlay-body">
|
||||
{activeItem.kind === "question" ? (
|
||||
<WorkbenchQuestionBody item={activeItem} />
|
||||
) : (
|
||||
<DynamicCardBody item={activeItem} surface="workbench" />
|
||||
)}
|
||||
</div>
|
||||
<div id="workbench-overlay-footer">
|
||||
{busy ? (
|
||||
<div class="workbench-overlay-status">Saving to workbench...</div>
|
||||
) : errorText ? (
|
||||
<div class="workbench-overlay-status error">{errorText}</div>
|
||||
) : activeItem.promotable ? (
|
||||
<div class="workbench-overlay-status">Add to feed when it is worth keeping.</div>
|
||||
) : (
|
||||
<div class="workbench-overlay-status">Temporary session artifact.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
399
frontend/src/components/cardFeed/inbox.tsx
Normal file
399
frontend/src/components/cardFeed/inbox.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { useEffect, useState } from "preact/hooks";
|
||||
import { decodeJsonError } from "../../cardRuntime/api";
|
||||
import { requestCardFeedRefresh } from "../../cardRuntime/store";
|
||||
|
||||
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
|
||||
|
||||
interface InboxItem {
|
||||
path: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
source: string;
|
||||
confidence: number | null;
|
||||
captured: string;
|
||||
updated: string;
|
||||
suggestedDue: string;
|
||||
tags: string[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
function normalizeTaskTag(raw: string): string {
|
||||
const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-");
|
||||
return trimmed ? `#${trimmed}` : "";
|
||||
}
|
||||
|
||||
function normalizeTaskTags(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const value of raw) {
|
||||
const tag = normalizeTaskTag(String(value || ""));
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function normalizeInboxItem(raw: unknown): InboxItem | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path.trim() : "";
|
||||
if (!path) return null;
|
||||
return {
|
||||
path,
|
||||
title:
|
||||
typeof record.title === "string" && record.title.trim()
|
||||
? record.title.trim()
|
||||
: "Untitled capture",
|
||||
kind: typeof record.kind === "string" ? record.kind.trim() : "unknown",
|
||||
status: typeof record.status === "string" ? record.status.trim() : "new",
|
||||
source: typeof record.source === "string" ? record.source.trim() : "unknown",
|
||||
confidence:
|
||||
typeof record.confidence === "number" && Number.isFinite(record.confidence)
|
||||
? record.confidence
|
||||
: null,
|
||||
captured: typeof record.captured === "string" ? record.captured.trim() : "",
|
||||
updated: typeof record.updated === "string" ? record.updated.trim() : "",
|
||||
suggestedDue: typeof record.suggested_due === "string" ? record.suggested_due.trim() : "",
|
||||
tags: normalizeTaskTags(record.tags),
|
||||
body: typeof record.body === "string" ? record.body : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchInboxItems(limit = 4): Promise<InboxItem[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
const resp = await fetch(`/inbox?${params.toString()}`, { cache: "no-store" });
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
const payload = (await resp.json()) as { items?: unknown };
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
return items.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null);
|
||||
}
|
||||
|
||||
function requestInboxRefresh(): void {
|
||||
window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
|
||||
}
|
||||
|
||||
function extractCaptureTags(text: string): string[] {
|
||||
const matches = text.match(/(^|\s)#([A-Za-z0-9_-]+)/g) ?? [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
matches
|
||||
.map((match) => match.trim())
|
||||
.filter(Boolean)
|
||||
.map((tag) => normalizeTaskTag(tag)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function captureInboxItem(text: string): Promise<InboxItem> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("capture text is required");
|
||||
const resp = await fetch("/inbox/capture", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: trimmed,
|
||||
tags: extractCaptureTags(trimmed),
|
||||
source: "web-ui",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
const payload = (await resp.json()) as { item?: unknown };
|
||||
const item = normalizeInboxItem(payload.item);
|
||||
if (!item) throw new Error("invalid inbox response");
|
||||
requestInboxRefresh();
|
||||
return item;
|
||||
}
|
||||
|
||||
async function dismissInboxItem(itemPath: string): Promise<void> {
|
||||
const resp = await fetch("/inbox/dismiss", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ item: itemPath }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
requestInboxRefresh();
|
||||
}
|
||||
|
||||
async function acceptInboxItemAsTask(itemPath: string): Promise<void> {
|
||||
const resp = await fetch("/inbox/accept-task", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ item: itemPath, lane: "backlog" }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
requestInboxRefresh();
|
||||
requestCardFeedRefresh();
|
||||
}
|
||||
|
||||
function formatInboxUpdatedAt(timestamp: string): string {
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return parsed.toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeInboxBody(body: string): string {
|
||||
return body.replace(/\r\n?/g, "\n").split("\n## Raw Capture")[0].replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function computeInboxScore(items: InboxItem[]): number {
|
||||
if (!items.length) return 0;
|
||||
const newest = items[0];
|
||||
const newestMs = newest.updated ? new Date(newest.updated).getTime() : Number.NaN;
|
||||
const ageHours = Number.isFinite(newestMs) ? (Date.now() - newestMs) / (60 * 60 * 1000) : 999;
|
||||
if (ageHours <= 1) return 91;
|
||||
if (ageHours <= 6) return 86;
|
||||
if (ageHours <= 24) return 82;
|
||||
return 78;
|
||||
}
|
||||
|
||||
export function InboxQuickAdd({
|
||||
onSubmit,
|
||||
visible,
|
||||
}: {
|
||||
onSubmit(text: string): Promise<void>;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setOpen(false);
|
||||
setError("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const close = () => {
|
||||
if (busy) return;
|
||||
setOpen(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
setValue("");
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id="inbox-quick-add-btn"
|
||||
type="button"
|
||||
data-no-swipe="1"
|
||||
aria-label="Quick add to inbox"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{open ? (
|
||||
<>
|
||||
<button
|
||||
id="inbox-quick-add-backdrop"
|
||||
type="button"
|
||||
data-no-swipe="1"
|
||||
aria-label="Close inbox quick add"
|
||||
onClick={close}
|
||||
/>
|
||||
<div id="inbox-quick-add-sheet" data-no-swipe="1">
|
||||
<div class="inbox-quick-add-sheet__title">Quick Add</div>
|
||||
<textarea
|
||||
class="inbox-quick-add-sheet__input"
|
||||
rows={3}
|
||||
placeholder="Something to remember..."
|
||||
value={value}
|
||||
disabled={busy}
|
||||
onInput={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
{error ? <div class="inbox-quick-add-sheet__error">{error}</div> : null}
|
||||
<div class="inbox-quick-add-sheet__actions">
|
||||
<button
|
||||
class="inbox-quick-add-sheet__btn"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="inbox-quick-add-sheet__btn primary"
|
||||
type="button"
|
||||
disabled={busy || !value.trim()}
|
||||
onClick={() => {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxReviewCard() {
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyItem, setBusyItem] = useState<{ path: string; action: "accept" | "dismiss" } | null>(
|
||||
null,
|
||||
);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
|
||||
const loadInbox = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextItems = await fetchInboxItems(4);
|
||||
setItems(nextItems);
|
||||
setErrorText("");
|
||||
} catch (error) {
|
||||
console.error("Inbox load failed", error);
|
||||
setErrorText(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadInbox();
|
||||
const onRefresh = (event?: Event) => {
|
||||
const customEvent = event as CustomEvent<{ items?: unknown[] }> | undefined;
|
||||
const nextItems = customEvent?.detail?.items;
|
||||
if (Array.isArray(nextItems)) {
|
||||
setItems(
|
||||
nextItems.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null),
|
||||
);
|
||||
setLoading(false);
|
||||
setErrorText("");
|
||||
return;
|
||||
}
|
||||
void loadInbox();
|
||||
};
|
||||
window.addEventListener(INBOX_REFRESH_EVENT, onRefresh);
|
||||
window.addEventListener("focus", onRefresh);
|
||||
return () => {
|
||||
window.removeEventListener(INBOX_REFRESH_EVENT, onRefresh);
|
||||
window.removeEventListener("focus", onRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const withBusyItem = async (
|
||||
itemPath: string,
|
||||
actionKind: "accept" | "dismiss",
|
||||
action: () => Promise<void>,
|
||||
) => {
|
||||
setBusyItem({ path: itemPath, action: actionKind });
|
||||
setErrorText("");
|
||||
try {
|
||||
await action();
|
||||
await loadInbox();
|
||||
} catch (error) {
|
||||
console.error("Inbox action failed", error);
|
||||
setErrorText(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyItem(null);
|
||||
}
|
||||
};
|
||||
|
||||
const score = computeInboxScore(items);
|
||||
|
||||
if (!loading && !errorText && items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article class="card kind-text state-active inbox-review-card">
|
||||
<header class="card-header">
|
||||
<div class="card-title-wrap">
|
||||
<div class="card-title-line">
|
||||
<span class="card-title">Inbox</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-state state-active">
|
||||
{loading ? "Loading" : `${items.length} open`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="inbox-review-card__body">
|
||||
<div class="inbox-review-card__summary">
|
||||
Capture first, organize later.
|
||||
{score ? <span class="inbox-review-card__score">Priority {score}</span> : null}
|
||||
</div>
|
||||
{errorText ? <div class="inbox-review-card__error">{errorText}</div> : null}
|
||||
{items.map((item) => {
|
||||
const preview = summarizeInboxBody(item.body);
|
||||
const itemBusy = busyItem?.path === item.path;
|
||||
const accepting = busyItem?.path === item.path && busyItem.action === "accept";
|
||||
return (
|
||||
<div key={item.path} class="inbox-review-card__item">
|
||||
<div class="inbox-review-card__item-topline">
|
||||
<span class="inbox-review-card__item-kind">{item.kind}</span>
|
||||
<span class={`inbox-review-card__item-updated${accepting ? " is-active" : ""}`}>
|
||||
{accepting ? "Nanobot..." : formatInboxUpdatedAt(item.updated || item.captured)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inbox-review-card__item-title">{item.title}</div>
|
||||
{preview ? <div class="inbox-review-card__item-preview">{preview}</div> : null}
|
||||
{item.tags.length ? (
|
||||
<div class="inbox-review-card__tags">
|
||||
{item.tags.map((tag) => (
|
||||
<span key={tag} class="inbox-review-card__tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div class="inbox-review-card__actions">
|
||||
<button
|
||||
class="inbox-review-card__action"
|
||||
type="button"
|
||||
disabled={itemBusy}
|
||||
onClick={() => {
|
||||
void withBusyItem(item.path, "accept", () => acceptInboxItemAsTask(item.path));
|
||||
}}
|
||||
>
|
||||
{accepting ? "Nanobot..." : "Ask Nanobot"}
|
||||
</button>
|
||||
<button
|
||||
class="inbox-review-card__action ghost"
|
||||
type="button"
|
||||
disabled={itemBusy}
|
||||
onClick={() => {
|
||||
void withBusyItem(item.path, "dismiss", () => dismissInboxItem(item.path));
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/cardFeed/snooze.ts
Normal file
19
frontend/src/components/cardFeed/snooze.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { decodeJsonError } from "../../cardRuntime/api";
|
||||
|
||||
function nextLocalMidnightIso(): string {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(24, 0, 0, 0);
|
||||
return tomorrow.toISOString();
|
||||
}
|
||||
|
||||
export async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise<void> {
|
||||
const key = (cardId || "").trim();
|
||||
if (!key) throw new Error("card id is required");
|
||||
const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ until: nextLocalMidnightIso() }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await decodeJsonError(resp));
|
||||
window.dispatchEvent(new Event("nanobot:cards-refresh"));
|
||||
}
|
||||
|
|
@ -1,735 +1,254 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import type {
|
||||
AgentState,
|
||||
CardItem,
|
||||
CardMessageMetadata,
|
||||
CardState,
|
||||
ClientMessage,
|
||||
LogLine,
|
||||
ServerMessage,
|
||||
} from "../types";
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
||||
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
|
||||
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
|
||||
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
|
||||
|
||||
let cardIdCounter = 0;
|
||||
let logIdCounter = 0;
|
||||
|
||||
const STATE_RANK: Record<CardState, number> = {
|
||||
active: 0,
|
||||
stale: 1,
|
||||
resolved: 2,
|
||||
superseded: 3,
|
||||
archived: 4,
|
||||
};
|
||||
|
||||
interface WebRTCState {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
textOnly: boolean;
|
||||
agentState: AgentState;
|
||||
logLines: LogLine[];
|
||||
cards: CardItem[];
|
||||
voiceStatus: string;
|
||||
statusVisible: boolean;
|
||||
remoteAudioEl: HTMLAudioElement | null;
|
||||
remoteStream: MediaStream | null;
|
||||
sendJson(msg: ClientMessage): void;
|
||||
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
|
||||
dismissCard(id: number): void;
|
||||
setTextOnly(enabled: boolean): void;
|
||||
connect(): Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface RTCRefs {
|
||||
pcRef: { current: RTCPeerConnection | null };
|
||||
dcRef: { current: RTCDataChannel | null };
|
||||
remoteAudioRef: { current: HTMLAudioElement | null };
|
||||
micSendersRef: { current: RTCRtpSender[] };
|
||||
localTracksRef: { current: MediaStreamTrack[] };
|
||||
}
|
||||
|
||||
interface RTCCallbacks {
|
||||
setConnected: (v: boolean) => void;
|
||||
setConnecting: (v: boolean) => void;
|
||||
setRemoteStream: (s: MediaStream | null) => void;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
appendLine: AppendLine;
|
||||
onDcMessage: (raw: string) => void;
|
||||
onDcOpen: () => void;
|
||||
closePC: () => void;
|
||||
}
|
||||
|
||||
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
|
||||
if (!card.serverId) return card.priority;
|
||||
const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
|
||||
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
|
||||
return card.priority;
|
||||
}
|
||||
const score = (liveContent as Record<string, unknown>).score;
|
||||
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
|
||||
}
|
||||
|
||||
function compareCards(a: CardItem, b: CardItem): number {
|
||||
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
|
||||
if (stateDiff !== 0) return stateDiff;
|
||||
const scoreDiff = readCardScore(b) - readCardScore(a);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
|
||||
if (updatedDiff !== 0) return updatedDiff;
|
||||
return b.createdAt.localeCompare(a.createdAt);
|
||||
}
|
||||
|
||||
function sortCards(items: CardItem[]): CardItem[] {
|
||||
return [...items].sort(compareCards);
|
||||
}
|
||||
|
||||
function 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,
|
||||
kind: msg.kind,
|
||||
content: msg.content,
|
||||
title: msg.title,
|
||||
question: msg.question || undefined,
|
||||
choices: msg.choices.length > 0 ? msg.choices : undefined,
|
||||
responseValue: msg.response_value || undefined,
|
||||
slot: msg.slot || undefined,
|
||||
lane: msg.lane,
|
||||
priority: msg.priority,
|
||||
state: msg.state,
|
||||
templateKey: msg.template_key || undefined,
|
||||
templateState:
|
||||
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
|
||||
contextSummary: msg.context_summary || undefined,
|
||||
createdAt: msg.created_at || new Date().toISOString(),
|
||||
updatedAt: msg.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
appendLine: AppendLine,
|
||||
upsertCard: UpsertCard,
|
||||
idleFallback: IdleFallbackControls,
|
||||
): void {
|
||||
if (msg.type === "agent_state") {
|
||||
idleFallback.clear();
|
||||
setAgentState(() => msg.state);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "message") {
|
||||
if (msg.is_tool_hint) {
|
||||
appendLine("tool", msg.content, msg.timestamp);
|
||||
return;
|
||||
}
|
||||
if (!msg.is_progress) {
|
||||
appendLine(msg.role, msg.content, msg.timestamp);
|
||||
idleFallback.schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "card") {
|
||||
upsertCard(toCardItem(msg));
|
||||
idleFallback.schedule();
|
||||
return;
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
appendLine("system", msg.error, "");
|
||||
idleFallback.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireMicStream(): Promise<MediaStream> {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
} catch {
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
}
|
||||
}
|
||||
|
||||
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
pc.removeEventListener("icegatheringstatechange", check);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener("icegatheringstatechange", check);
|
||||
setTimeout(() => {
|
||||
pc.removeEventListener("icegatheringstatechange", check);
|
||||
resolve();
|
||||
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeSdp(
|
||||
localDesc: RTCSessionDescription,
|
||||
): Promise<{ sdp: string; rtcType: string }> {
|
||||
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
|
||||
const resp = await fetch(rtcUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
|
||||
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
|
||||
}
|
||||
|
||||
async function runConnect(
|
||||
refs: RTCRefs,
|
||||
cbs: RTCCallbacks,
|
||||
opts: { textOnly: boolean },
|
||||
): Promise<void> {
|
||||
if (refs.pcRef.current) return;
|
||||
if (!window.RTCPeerConnection) {
|
||||
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
|
||||
return;
|
||||
}
|
||||
cbs.setConnecting(true);
|
||||
cbs.showStatus("Connecting...");
|
||||
|
||||
let micStream: MediaStream | null = null;
|
||||
try {
|
||||
refs.localTracksRef.current = [];
|
||||
if (!opts.textOnly) {
|
||||
micStream = await acquireMicStream();
|
||||
const audioTracks = micStream.getAudioTracks();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = false;
|
||||
});
|
||||
refs.localTracksRef.current = audioTracks;
|
||||
}
|
||||
|
||||
// Local-only deployments do not need a public STUN server; host candidates are enough
|
||||
// and avoiding external ICE gathering removes several seconds from startup latency.
|
||||
const pc = new RTCPeerConnection(
|
||||
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
|
||||
);
|
||||
refs.pcRef.current = pc;
|
||||
|
||||
const newRemoteStream = new MediaStream();
|
||||
cbs.setRemoteStream(newRemoteStream);
|
||||
if (refs.remoteAudioRef.current) {
|
||||
refs.remoteAudioRef.current.srcObject = newRemoteStream;
|
||||
refs.remoteAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
if (event.track.kind !== "audio") return;
|
||||
newRemoteStream.addTrack(event.track);
|
||||
refs.remoteAudioRef.current?.play().catch(() => {});
|
||||
};
|
||||
|
||||
const dc = pc.createDataChannel("app", { ordered: true });
|
||||
refs.dcRef.current = dc;
|
||||
dc.onopen = () => {
|
||||
cbs.setConnected(true);
|
||||
cbs.setConnecting(false);
|
||||
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
|
||||
cbs.appendLine("system", "Connected.", new Date().toISOString());
|
||||
cbs.onDcOpen();
|
||||
};
|
||||
dc.onclose = () => {
|
||||
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
|
||||
cbs.closePC();
|
||||
};
|
||||
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
|
||||
|
||||
refs.micSendersRef.current = [];
|
||||
if (micStream) {
|
||||
micStream.getAudioTracks().forEach((track) => {
|
||||
pc.addTrack(track, micStream as MediaStream);
|
||||
});
|
||||
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceComplete(pc);
|
||||
|
||||
const localDesc = pc.localDescription;
|
||||
if (!localDesc) throw new Error("No local description after ICE gathering");
|
||||
const answer = await exchangeSdp(localDesc);
|
||||
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
|
||||
} catch (err) {
|
||||
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
|
||||
cbs.showStatus("Connection failed.", 3000);
|
||||
cbs.closePC();
|
||||
}
|
||||
}
|
||||
|
||||
function useBackendActions() {
|
||||
const sendTextMessage = useCallback(async (text: string, metadata?: CardMessageMetadata) => {
|
||||
const message = text.trim();
|
||||
if (!message) return;
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: message, metadata: metadata ?? {} }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
|
||||
}, []);
|
||||
|
||||
return { sendTextMessage };
|
||||
}
|
||||
|
||||
function useCardPolling(loadPersistedCards: () => Promise<void>) {
|
||||
useEffect(() => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
const pollId = window.setInterval(() => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
}, 10000);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
|
||||
};
|
||||
const onCardsRefresh = () => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
};
|
||||
window.addEventListener("focus", onVisible);
|
||||
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.clearInterval(pollId);
|
||||
window.removeEventListener("focus", onVisible);
|
||||
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [loadPersistedCards]);
|
||||
}
|
||||
|
||||
function useRemoteAudioBindings({
|
||||
textOnly,
|
||||
connected,
|
||||
showStatus,
|
||||
remoteAudioRef,
|
||||
micSendersRef,
|
||||
dcRef,
|
||||
textOnlyRef,
|
||||
}: {
|
||||
textOnly: boolean;
|
||||
connected: boolean;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
remoteAudioRef: { current: HTMLAudioElement | null };
|
||||
micSendersRef: { current: RTCRtpSender[] };
|
||||
dcRef: { current: RTCDataChannel | null };
|
||||
textOnlyRef: { current: boolean };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
textOnlyRef.current = textOnly;
|
||||
}, [textOnly, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
|
||||
audio.muted = textOnlyRef.current;
|
||||
remoteAudioRef.current = audio;
|
||||
return () => {
|
||||
audio.srcObject = null;
|
||||
};
|
||||
}, [remoteAudioRef, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
|
||||
micSendersRef.current.forEach((sender) => {
|
||||
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
|
||||
});
|
||||
};
|
||||
window.addEventListener("nanobot-mic-enable", handler);
|
||||
return () => window.removeEventListener("nanobot-mic-enable", handler);
|
||||
}, [micSendersRef, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteAudioRef.current) {
|
||||
remoteAudioRef.current.muted = textOnly;
|
||||
if (textOnly) remoteAudioRef.current.pause();
|
||||
else remoteAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
micSendersRef.current.forEach((sender) => {
|
||||
if (sender.track) sender.track.enabled = false;
|
||||
});
|
||||
if (textOnly) {
|
||||
const dc = dcRef.current;
|
||||
if (dc?.readyState === "open") {
|
||||
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
|
||||
}
|
||||
}
|
||||
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
|
||||
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
|
||||
}
|
||||
|
||||
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
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) => mergeCardItem(prev, item));
|
||||
}, []);
|
||||
|
||||
const dismissCard = useCallback((id: number) => {
|
||||
setCards((prev) => {
|
||||
const card = prev.find((entry) => entry.id === id);
|
||||
if (card?.serverId) {
|
||||
const url = BACKEND_URL
|
||||
? `${BACKEND_URL}/cards/${card.serverId}`
|
||||
: `/cards/${card.serverId}`;
|
||||
fetch(url, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
return prev.filter((entry) => entry.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadPersistedCards = useCallback(async () => {
|
||||
try {
|
||||
const rawCards = await fetchPersistedCardsFromBackend();
|
||||
if (!rawCards) return;
|
||||
setCards((prev) => reconcilePersistedCards(prev, rawCards));
|
||||
} catch (err) {
|
||||
console.warn("[cards] failed to load persisted cards", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onCardLiveContentChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
|
||||
const cardId =
|
||||
customEvent.detail && typeof customEvent.detail.cardId === "string"
|
||||
? customEvent.detail.cardId
|
||||
: "";
|
||||
setCards((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
|
||||
return sortCards([...prev]);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
|
||||
return () => {
|
||||
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
const msg = parseServerMessage(raw);
|
||||
if (!msg) return;
|
||||
handleTypedMessage(msg, setAgentState, appendLine, upsertCard, idleFallback);
|
||||
},
|
||||
[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 };
|
||||
}
|
||||
|
||||
function usePeerConnectionControls({
|
||||
textOnly,
|
||||
connected,
|
||||
appendLine,
|
||||
onDcMessage,
|
||||
loadPersistedCards,
|
||||
showStatus,
|
||||
refs,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
textOnlyRef,
|
||||
}: {
|
||||
textOnly: boolean;
|
||||
connected: boolean;
|
||||
appendLine: AppendLine;
|
||||
onDcMessage: (raw: string) => void;
|
||||
loadPersistedCards: () => Promise<void>;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
refs: RTCRefs;
|
||||
setConnected: (value: boolean) => void;
|
||||
setConnecting: (value: boolean) => void;
|
||||
setRemoteStream: (stream: MediaStream | null) => void;
|
||||
textOnlyRef: { current: boolean };
|
||||
}) {
|
||||
const closePC = useCallback(() => {
|
||||
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 = 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(
|
||||
refs,
|
||||
{
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
showStatus,
|
||||
appendLine,
|
||||
onDcMessage,
|
||||
onDcOpen: () => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
},
|
||||
closePC,
|
||||
},
|
||||
{ textOnly: textOnlyRef.current },
|
||||
);
|
||||
}, [
|
||||
appendLine,
|
||||
closePC,
|
||||
loadPersistedCards,
|
||||
onDcMessage,
|
||||
refs,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
showStatus,
|
||||
textOnlyRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
|
||||
closePC();
|
||||
connect().catch(() => {});
|
||||
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closePCRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { closePC, connect };
|
||||
}
|
||||
|
||||
import type { CardMessageMetadata, ClientMessage, SessionSummary } from "../types";
|
||||
import {
|
||||
createSessionInBackend,
|
||||
deleteSessionInBackend,
|
||||
fetchSessionDetailFromBackend,
|
||||
fetchSessionsFromBackend,
|
||||
renameSessionInBackend,
|
||||
useBackendActions,
|
||||
} from "./webrtc/backend";
|
||||
import { useSessionSurfaceEvents } from "./webrtc/cards";
|
||||
import { useMessageState } from "./webrtc/messages";
|
||||
import {
|
||||
usePeerConnectionControls,
|
||||
useRemoteAudioBindings,
|
||||
useRtcRefs,
|
||||
} from "./webrtc/rtcTransport";
|
||||
import type { WebRTCState } from "./webrtc/types";
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this hook owns the app's transport/session state wiring
|
||||
export function useWebRTC(): WebRTCState {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [textOnly, setTextOnlyState] = useState(false);
|
||||
const [textOnly, setTextOnlyState] = useState(true);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState("web");
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [textStreaming, setTextStreaming] = useState(false);
|
||||
const [voiceStatus, setVoiceStatus] = useState("");
|
||||
const [statusVisible, setStatusVisible] = useState(false);
|
||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
||||
const refs: RTCRefs = {
|
||||
pcRef: useRef<RTCPeerConnection | null>(null),
|
||||
dcRef: useRef<RTCDataChannel | null>(null),
|
||||
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
|
||||
micSendersRef: useRef<RTCRtpSender[]>([]),
|
||||
localTracksRef: useRef<MediaStreamTrack[]>([]),
|
||||
};
|
||||
const refs = useRtcRefs();
|
||||
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textOnlyRef = useRef(false);
|
||||
const { sendTextMessage } = useBackendActions();
|
||||
const { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage } =
|
||||
useMessageState();
|
||||
const textOnlyRef = useRef(true);
|
||||
const activeSessionIdRef = useRef("web");
|
||||
const {
|
||||
agentState,
|
||||
setAgentState,
|
||||
logLines,
|
||||
cards,
|
||||
workbenchItems,
|
||||
appendLine,
|
||||
replaceLines,
|
||||
clearLines,
|
||||
clearCards,
|
||||
clearWorkbench,
|
||||
dismissCard,
|
||||
dismissWorkbenchItem,
|
||||
promoteWorkbenchItem,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
onDcMessage,
|
||||
onIncomingMessage,
|
||||
suppressRtcTextRef,
|
||||
} = useMessageState(activeSessionIdRef);
|
||||
const { sendTextMessage: sendTextMessageRaw } = useBackendActions({
|
||||
onMessage: onIncomingMessage,
|
||||
suppressRtcTextRef,
|
||||
getActiveSessionId: () => activeSessionIdRef.current,
|
||||
setTextStreaming,
|
||||
});
|
||||
const activeSession = sessions.find((session) => session.chat_id === activeSessionId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const nextSessions = await fetchSessionsFromBackend();
|
||||
if (nextSessions) setSessions(nextSessions);
|
||||
return nextSessions;
|
||||
}, []);
|
||||
|
||||
const setTextOnly = useCallback((enabled: boolean) => {
|
||||
textOnlyRef.current = enabled;
|
||||
setTextOnlyState(enabled);
|
||||
}, []);
|
||||
|
||||
const showStatus = useCallback((text: string, persistMs = 0) => {
|
||||
setVoiceStatus(text);
|
||||
setStatusVisible(true);
|
||||
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
||||
if (persistMs > 0)
|
||||
if (persistMs > 0) {
|
||||
statusTimerRef.current = setTimeout(() => setStatusVisible(false), persistMs);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendJson = useCallback(
|
||||
(msg: ClientMessage) => {
|
||||
const dc = refs.dcRef.current;
|
||||
if (dc?.readyState === "open") dc.send(JSON.stringify(msg));
|
||||
if (dc?.readyState !== "open") return;
|
||||
if (msg.type === "command" || msg.type === "voice-ptt") {
|
||||
dc.send(JSON.stringify({ ...msg, chat_id: activeSessionIdRef.current }));
|
||||
return;
|
||||
}
|
||||
dc.send(JSON.stringify(msg));
|
||||
},
|
||||
[refs.dcRef],
|
||||
);
|
||||
|
||||
useCardPolling(loadPersistedCards);
|
||||
const sendTextMessage = useCallback(
|
||||
async (text: string, metadata?: CardMessageMetadata) => {
|
||||
await sendTextMessageRaw(text, metadata);
|
||||
await refreshSessions();
|
||||
},
|
||||
[refreshSessions, sendTextMessageRaw],
|
||||
);
|
||||
|
||||
const switchSession = useCallback(async (chatId: string) => {
|
||||
if (!chatId || chatId === activeSessionIdRef.current) return;
|
||||
activeSessionIdRef.current = chatId;
|
||||
setActiveSessionId(chatId);
|
||||
}, []);
|
||||
|
||||
const createSession = useCallback(
|
||||
async (title = "") => {
|
||||
setSessionLoading(true);
|
||||
try {
|
||||
const created = await createSessionInBackend(title);
|
||||
const nextSessions = await refreshSessions();
|
||||
if (!created) return;
|
||||
const nextSessionId = created.chat_id;
|
||||
activeSessionIdRef.current = nextSessionId;
|
||||
setActiveSessionId(nextSessionId);
|
||||
if (nextSessions && !nextSessions.some((session) => session.chat_id === nextSessionId)) {
|
||||
setSessions((prev) => [created, ...prev]);
|
||||
}
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
|
||||
const renameSession = useCallback(
|
||||
async (chatId: string, title: string) => {
|
||||
if (!chatId) return;
|
||||
setSessionLoading(true);
|
||||
try {
|
||||
const updated = await renameSessionInBackend(chatId, title);
|
||||
const nextSessions = await refreshSessions();
|
||||
if (!updated) return;
|
||||
if (nextSessions && !nextSessions.some((session) => session.chat_id === updated.chat_id)) {
|
||||
setSessions((prev) => [
|
||||
updated,
|
||||
...prev.filter((session) => session.chat_id !== updated.chat_id),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
|
||||
const deleteSession = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (!chatId) return;
|
||||
setSessionLoading(true);
|
||||
try {
|
||||
const deleted = await deleteSessionInBackend(chatId);
|
||||
const nextSessions = await refreshSessions();
|
||||
if (!deleted) return;
|
||||
|
||||
if (chatId !== activeSessionIdRef.current) return;
|
||||
|
||||
const fallbackSessionId =
|
||||
nextSessions?.find((session) => session.chat_id !== chatId)?.chat_id || "web";
|
||||
activeSessionIdRef.current = fallbackSessionId;
|
||||
setActiveSessionId(fallbackSessionId);
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions().then((nextSessions) => {
|
||||
if (
|
||||
!nextSessions ||
|
||||
nextSessions.some((session) => session.chat_id === activeSessionIdRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextSessions[0]?.chat_id) {
|
||||
activeSessionIdRef.current = nextSessions[0].chat_id;
|
||||
setActiveSessionId(nextSessions[0].chat_id);
|
||||
}
|
||||
});
|
||||
}, [refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadSession = async () => {
|
||||
setSessionLoading(true);
|
||||
setAgentState(() => "idle");
|
||||
clearLines();
|
||||
clearCards();
|
||||
clearWorkbench();
|
||||
|
||||
try {
|
||||
const detail = await fetchSessionDetailFromBackend(activeSessionId);
|
||||
if (cancelled || activeSessionIdRef.current !== activeSessionId) return;
|
||||
if (detail) {
|
||||
replaceLines(detail.messages);
|
||||
setSessions((prev) => {
|
||||
const filtered = prev.filter((session) => session.chat_id !== detail.session.chat_id);
|
||||
return [detail.session, ...filtered].sort((a, b) =>
|
||||
b.updated_at.localeCompare(a.updated_at),
|
||||
);
|
||||
});
|
||||
}
|
||||
await loadPersistedCards();
|
||||
await loadWorkbench();
|
||||
} finally {
|
||||
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSession().catch((err) => {
|
||||
console.warn("[sessions] failed to load session", err);
|
||||
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
clearCards,
|
||||
clearLines,
|
||||
clearWorkbench,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
replaceLines,
|
||||
setAgentState,
|
||||
]);
|
||||
|
||||
useSessionSurfaceEvents({
|
||||
activeSessionId,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
refreshSessions,
|
||||
setSessions,
|
||||
});
|
||||
useRemoteAudioBindings({
|
||||
textOnly,
|
||||
connected,
|
||||
|
|
@ -745,6 +264,7 @@ export function useWebRTC(): WebRTCState {
|
|||
appendLine,
|
||||
onDcMessage,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
showStatus,
|
||||
refs,
|
||||
setConnected,
|
||||
|
|
@ -760,6 +280,12 @@ export function useWebRTC(): WebRTCState {
|
|||
agentState,
|
||||
logLines,
|
||||
cards,
|
||||
workbenchItems,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
sessionLoading,
|
||||
textStreaming,
|
||||
voiceStatus,
|
||||
statusVisible,
|
||||
remoteAudioEl: refs.remoteAudioRef.current,
|
||||
|
|
@ -767,7 +293,13 @@ export function useWebRTC(): WebRTCState {
|
|||
sendJson,
|
||||
sendTextMessage,
|
||||
dismissCard,
|
||||
dismissWorkbenchItem,
|
||||
promoteWorkbenchItem,
|
||||
setTextOnly,
|
||||
switchSession,
|
||||
createSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
connect,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
140
frontend/src/hooks/webrtc/backend.ts
Normal file
140
frontend/src/hooks/webrtc/backend.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useCallback } from "preact/hooks";
|
||||
import { streamSseResponse } from "../../lib/sse";
|
||||
import type { CardMessageMetadata, SessionSummary } from "../../types";
|
||||
import type { RawPersistedCard, RawWorkbenchItem, SessionDetailResponse } from "./types";
|
||||
|
||||
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
||||
|
||||
export function useBackendActions({
|
||||
onMessage,
|
||||
suppressRtcTextRef,
|
||||
getActiveSessionId,
|
||||
setTextStreaming,
|
||||
}: {
|
||||
onMessage: (raw: string) => void;
|
||||
suppressRtcTextRef: { current: number };
|
||||
getActiveSessionId: () => string;
|
||||
setTextStreaming: (value: boolean) => void;
|
||||
}) {
|
||||
const sendTextMessage = useCallback(
|
||||
async (text: string, metadata?: CardMessageMetadata) => {
|
||||
const message = text.trim();
|
||||
if (!message) return;
|
||||
suppressRtcTextRef.current += 1;
|
||||
setTextStreaming(true);
|
||||
try {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/message/stream` : "/message/stream";
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: message,
|
||||
metadata: metadata ?? {},
|
||||
chat_id: getActiveSessionId(),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
|
||||
await streamSseResponse(resp, onMessage);
|
||||
} finally {
|
||||
setTextStreaming(false);
|
||||
suppressRtcTextRef.current = Math.max(0, suppressRtcTextRef.current - 1);
|
||||
}
|
||||
},
|
||||
[getActiveSessionId, onMessage, setTextStreaming, suppressRtcTextRef],
|
||||
);
|
||||
|
||||
return { sendTextMessage };
|
||||
}
|
||||
|
||||
export async function fetchPersistedCardsFromBackend(
|
||||
chatId: string,
|
||||
): Promise<RawPersistedCard[] | null> {
|
||||
const params = new URLSearchParams({ chat_id: chatId });
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/cards?${params}` : `/cards?${params}`;
|
||||
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[];
|
||||
}
|
||||
|
||||
export async function fetchWorkbenchFromBackend(
|
||||
chatId: string,
|
||||
): Promise<RawWorkbenchItem[] | null> {
|
||||
const params = new URLSearchParams({ chat_id: chatId });
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/workbench?${params}` : `/workbench?${params}`;
|
||||
const resp = await fetch(url, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
console.warn(`[workbench] /workbench returned ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const payload = (await resp.json()) as { items?: RawWorkbenchItem[] };
|
||||
return Array.isArray(payload.items) ? payload.items : null;
|
||||
}
|
||||
|
||||
export async function fetchSessionsFromBackend(): Promise<SessionSummary[] | null> {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
|
||||
const resp = await fetch(url, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
console.warn(`[sessions] /sessions returned ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const payload = (await resp.json()) as { sessions?: SessionSummary[] };
|
||||
return Array.isArray(payload.sessions) ? payload.sessions : null;
|
||||
}
|
||||
|
||||
export async function fetchSessionDetailFromBackend(
|
||||
chatId: string,
|
||||
): Promise<SessionDetailResponse | null> {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
|
||||
const resp = await fetch(url, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
console.warn(`[sessions] /sessions/${chatId} returned ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as SessionDetailResponse;
|
||||
}
|
||||
|
||||
export async function createSessionInBackend(title = ""): Promise<SessionSummary | null> {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[sessions] POST /sessions returned ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const payload = (await resp.json()) as { session?: SessionSummary };
|
||||
return payload.session ?? null;
|
||||
}
|
||||
|
||||
export async function renameSessionInBackend(
|
||||
chatId: string,
|
||||
title: string,
|
||||
): Promise<SessionSummary | null> {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
|
||||
const resp = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[sessions] PATCH /sessions/${chatId} returned ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const payload = (await resp.json()) as { session?: SessionSummary };
|
||||
return payload.session ?? null;
|
||||
}
|
||||
|
||||
export async function deleteSessionInBackend(chatId: string): Promise<boolean> {
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
|
||||
const resp = await fetch(url, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
console.warn(`[sessions] DELETE /sessions/${chatId} returned ${resp.status}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
543
frontend/src/hooks/webrtc/cards.ts
Normal file
543
frontend/src/hooks/webrtc/cards.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { getCardLiveContent } from "../../cardRuntime/store";
|
||||
import type { CardItem, CardState, JsonValue, SessionSummary, WorkbenchItem } from "../../types";
|
||||
import { BACKEND_URL, fetchPersistedCardsFromBackend, fetchWorkbenchFromBackend } from "./backend";
|
||||
import type { RawPersistedCard, RawWorkbenchItem } from "./types";
|
||||
|
||||
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
|
||||
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
|
||||
|
||||
let cardIdCounter = 0;
|
||||
|
||||
const STATE_RANK: Record<CardState, number> = {
|
||||
active: 0,
|
||||
stale: 1,
|
||||
resolved: 2,
|
||||
superseded: 3,
|
||||
archived: 4,
|
||||
};
|
||||
|
||||
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
|
||||
if (!card.serverId) return card.priority;
|
||||
const liveContent = getCardLiveContent(card.serverId);
|
||||
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
|
||||
return card.priority;
|
||||
}
|
||||
const score = (liveContent as Record<string, unknown>).score;
|
||||
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
|
||||
}
|
||||
|
||||
function compareCards(a: CardItem, b: CardItem): number {
|
||||
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
|
||||
if (stateDiff !== 0) return stateDiff;
|
||||
const scoreDiff = readCardScore(b) - readCardScore(a);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
const createdDiff = b.createdAt.localeCompare(a.createdAt);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return a.id - b.id;
|
||||
}
|
||||
|
||||
function readTaskGroupKey(
|
||||
card: Pick<CardItem, "serverId" | "slot" | "templateKey" | "templateState">,
|
||||
): string {
|
||||
const raw = card.templateState;
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const taskKey = raw.task_key;
|
||||
if (typeof taskKey === "string" && taskKey.trim()) return taskKey.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isTaskParentCard(card: Pick<CardItem, "templateKey" | "templateState">): boolean {
|
||||
return card.templateKey === "todo-item-live" && !!readTaskGroupKey(card);
|
||||
}
|
||||
|
||||
function isLinkedHelperCard(card: Pick<CardItem, "serverId" | "slot" | "templateState">): boolean {
|
||||
const taskGroupKey = readTaskGroupKey(card);
|
||||
if (!taskGroupKey) return false;
|
||||
const raw = card.templateState;
|
||||
const helperKind =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw) ? raw.helper_kind : undefined;
|
||||
return (
|
||||
(typeof helperKind === "string" && !!helperKind.trim()) ||
|
||||
(card.serverId ?? "").startsWith("task-helper-") ||
|
||||
(card.slot ?? "").startsWith("taskhelper:")
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskLinkedCardIndex {
|
||||
parentGroups: Set<string>;
|
||||
helpersByGroup: Map<string, CardItem[]>;
|
||||
}
|
||||
|
||||
function indexTaskLinkedCards(base: CardItem[]): TaskLinkedCardIndex {
|
||||
const parentGroups = new Set<string>();
|
||||
const helpersByGroup = new Map<string, CardItem[]>();
|
||||
|
||||
for (const card of base) {
|
||||
const groupKey = readTaskGroupKey(card);
|
||||
if (!groupKey) continue;
|
||||
if (isTaskParentCard(card)) {
|
||||
parentGroups.add(groupKey);
|
||||
continue;
|
||||
}
|
||||
if (!isLinkedHelperCard(card)) continue;
|
||||
const current = helpersByGroup.get(groupKey);
|
||||
if (current) current.push(card);
|
||||
else helpersByGroup.set(groupKey, [card]);
|
||||
}
|
||||
|
||||
return { parentGroups, helpersByGroup };
|
||||
}
|
||||
|
||||
function shouldDeferLinkedHelper(card: CardItem, index: TaskLinkedCardIndex): boolean {
|
||||
const groupKey = readTaskGroupKey(card);
|
||||
return !!groupKey && isLinkedHelperCard(card) && index.parentGroups.has(groupKey);
|
||||
}
|
||||
|
||||
function appendGroupedTaskHelpers(
|
||||
grouped: CardItem[],
|
||||
emitted: Set<number>,
|
||||
card: CardItem,
|
||||
index: TaskLinkedCardIndex,
|
||||
): void {
|
||||
const groupKey = readTaskGroupKey(card);
|
||||
if (!groupKey || !isTaskParentCard(card)) return;
|
||||
const helpers = index.helpersByGroup.get(groupKey) ?? [];
|
||||
for (const helper of helpers) {
|
||||
if (emitted.has(helper.id)) continue;
|
||||
grouped.push(helper);
|
||||
emitted.add(helper.id);
|
||||
}
|
||||
}
|
||||
|
||||
function sortCards(items: CardItem[]): CardItem[] {
|
||||
const base = [...items].sort(compareCards);
|
||||
const linkedIndex = indexTaskLinkedCards(base);
|
||||
|
||||
const emitted = new Set<number>();
|
||||
const grouped: CardItem[] = [];
|
||||
for (const card of base) {
|
||||
if (emitted.has(card.id)) continue;
|
||||
if (shouldDeferLinkedHelper(card, linkedIndex)) continue;
|
||||
grouped.push(card);
|
||||
emitted.add(card.id);
|
||||
appendGroupedTaskHelpers(grouped, emitted, card, linkedIndex);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function toCardItem(msg: Extract<RawPersistedCard, { type?: "card" }>): Omit<CardItem, "id"> {
|
||||
return {
|
||||
serverId: msg.id,
|
||||
kind: msg.kind,
|
||||
content: msg.content,
|
||||
title: msg.title,
|
||||
question: msg.question || undefined,
|
||||
choices: msg.choices.length > 0 ? msg.choices : undefined,
|
||||
responseValue: msg.response_value || undefined,
|
||||
slot: msg.slot || undefined,
|
||||
lane: msg.lane,
|
||||
priority: msg.priority,
|
||||
state: msg.state,
|
||||
templateKey: msg.template_key || undefined,
|
||||
templateState:
|
||||
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
|
||||
contextSummary: msg.context_summary || undefined,
|
||||
createdAt: msg.created_at || new Date().toISOString(),
|
||||
updatedAt: msg.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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<RawPersistedCard, { type?: "card" }>, "type">),
|
||||
});
|
||||
}
|
||||
|
||||
function areEquivalentCardItems(
|
||||
existing: CardItem,
|
||||
next: Omit<CardItem, "id"> & { id: number },
|
||||
): boolean {
|
||||
return (
|
||||
existing.serverId === next.serverId &&
|
||||
existing.kind === next.kind &&
|
||||
existing.content === next.content &&
|
||||
existing.title === next.title &&
|
||||
existing.question === next.question &&
|
||||
existing.responseValue === next.responseValue &&
|
||||
existing.slot === next.slot &&
|
||||
existing.lane === next.lane &&
|
||||
existing.priority === next.priority &&
|
||||
existing.state === next.state &&
|
||||
existing.templateKey === next.templateKey &&
|
||||
existing.contextSummary === next.contextSummary &&
|
||||
existing.createdAt === next.createdAt &&
|
||||
areStringArraysEqual(existing.choices, next.choices) &&
|
||||
areJsonRecordsEqual(existing.templateState, next.templateState)
|
||||
);
|
||||
}
|
||||
|
||||
function areStringArraysEqual(left: string[] | undefined, right: string[] | undefined): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return !left && !right;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function areJsonRecordsEqual(
|
||||
left: Record<string, JsonValue> | undefined,
|
||||
right: Record<string, JsonValue> | undefined,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return !left && !right;
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
|
||||
const byServerId = new Map(
|
||||
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card]),
|
||||
);
|
||||
const next = rawCards.map((raw) => {
|
||||
const card = normalizePersistedCard(raw);
|
||||
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
|
||||
return {
|
||||
...card,
|
||||
id: existing?.id ?? cardIdCounter++,
|
||||
};
|
||||
});
|
||||
const reused = next.map((card) => {
|
||||
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
|
||||
if (!existing) return card;
|
||||
return areEquivalentCardItems(existing, card) ? existing : card;
|
||||
});
|
||||
const sorted = sortCards(reused);
|
||||
if (sorted.length === prev.length && sorted.every((card, index) => card === prev[index])) {
|
||||
return prev;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function toWorkbenchItem(msg: Extract<RawWorkbenchItem, { type?: "workbench" }>): WorkbenchItem {
|
||||
return {
|
||||
id: msg.id,
|
||||
chatId: msg.chat_id,
|
||||
kind: msg.kind,
|
||||
title: msg.title,
|
||||
content: msg.content,
|
||||
question: msg.question || undefined,
|
||||
choices: msg.choices.length > 0 ? msg.choices : undefined,
|
||||
responseValue: msg.response_value || undefined,
|
||||
slot: msg.slot || undefined,
|
||||
templateKey: msg.template_key || undefined,
|
||||
templateState:
|
||||
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
|
||||
contextSummary: msg.context_summary || undefined,
|
||||
promotable: msg.promotable !== false,
|
||||
sourceCardId: msg.source_card_id || undefined,
|
||||
createdAt: msg.created_at || new Date().toISOString(),
|
||||
updatedAt: msg.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedWorkbench(raw: RawWorkbenchItem): WorkbenchItem {
|
||||
return toWorkbenchItem({
|
||||
type: "workbench",
|
||||
...(raw as Omit<Extract<RawWorkbenchItem, { type?: "workbench" }>, "type">),
|
||||
});
|
||||
}
|
||||
|
||||
function sortWorkbenchItems(items: WorkbenchItem[]): WorkbenchItem[] {
|
||||
return [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
function mergeWorkbenchItem(prev: WorkbenchItem[], item: WorkbenchItem): WorkbenchItem[] {
|
||||
const existingIndex = prev.findIndex((entry) => entry.id === item.id);
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...prev];
|
||||
next[existingIndex] = { ...next[existingIndex], ...item };
|
||||
return sortWorkbenchItems(next);
|
||||
}
|
||||
return sortWorkbenchItems([item, ...prev]);
|
||||
}
|
||||
|
||||
function reconcilePersistedWorkbench(
|
||||
prev: WorkbenchItem[],
|
||||
rawItems: RawWorkbenchItem[],
|
||||
): WorkbenchItem[] {
|
||||
const byId = new Map(prev.map((item) => [item.id, item]));
|
||||
const next = rawItems.map((raw) => {
|
||||
const item = normalizePersistedWorkbench(raw);
|
||||
const existing = byId.get(item.id);
|
||||
return existing ? { ...existing, ...item } : item;
|
||||
});
|
||||
return sortWorkbenchItems(next);
|
||||
}
|
||||
|
||||
interface SessionSurfaceEventsOptions {
|
||||
activeSessionId: string;
|
||||
loadPersistedCards: () => Promise<void>;
|
||||
loadWorkbench: () => Promise<void>;
|
||||
refreshSessions: () => Promise<SessionSummary[] | null>;
|
||||
setSessions: (sessions: SessionSummary[]) => void;
|
||||
}
|
||||
|
||||
function parseUiEventPayload(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const payload = JSON.parse(raw) as Record<string, unknown>;
|
||||
return payload && typeof payload === "object" ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionSurfaceEvent(
|
||||
payload: Record<string, unknown>,
|
||||
options: Pick<
|
||||
SessionSurfaceEventsOptions,
|
||||
"loadPersistedCards" | "loadWorkbench" | "refreshSessions" | "setSessions"
|
||||
>,
|
||||
): void {
|
||||
if (typeof payload.type !== "string") return;
|
||||
const { loadPersistedCards, loadWorkbench, refreshSessions, setSessions } = options;
|
||||
|
||||
switch (payload.type) {
|
||||
case "cards.changed":
|
||||
loadPersistedCards().catch(() => {});
|
||||
return;
|
||||
case "workbench.changed":
|
||||
loadWorkbench().catch(() => {});
|
||||
return;
|
||||
case "sessions.changed":
|
||||
if (Array.isArray(payload.sessions)) {
|
||||
setSessions(payload.sessions as SessionSummary[]);
|
||||
return;
|
||||
}
|
||||
refreshSessions().catch(() => {});
|
||||
return;
|
||||
case "inbox.changed":
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(INBOX_REFRESH_EVENT, {
|
||||
detail: { items: Array.isArray(payload.items) ? payload.items : undefined },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionSurfaceEvents({
|
||||
activeSessionId,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
refreshSessions,
|
||||
setSessions,
|
||||
}: SessionSurfaceEventsOptions) {
|
||||
useEffect(() => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
refreshSessions().catch(() => {});
|
||||
|
||||
let fallbackPollId: number | null = null;
|
||||
let source: EventSource | null = null;
|
||||
|
||||
const connectEvents = () => {
|
||||
const params = new URLSearchParams({ chat_id: activeSessionId });
|
||||
const url = BACKEND_URL ? `${BACKEND_URL}/events?${params}` : `/events?${params}`;
|
||||
source = new EventSource(url);
|
||||
source.onopen = () => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
refreshSessions().catch(() => {});
|
||||
};
|
||||
source.onmessage = (event) => {
|
||||
const payload = parseUiEventPayload(event.data);
|
||||
if (!payload) return;
|
||||
handleSessionSurfaceEvent(payload, {
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
refreshSessions,
|
||||
setSessions,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof EventSource === "function") {
|
||||
connectEvents();
|
||||
} else {
|
||||
fallbackPollId = window.setInterval(() => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
refreshSessions().catch(() => {});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
loadPersistedCards().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
refreshSessions().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onCardsRefresh = () => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
};
|
||||
const onWorkbenchRefresh = () => {
|
||||
loadWorkbench().catch(() => {});
|
||||
};
|
||||
window.addEventListener("focus", onVisible);
|
||||
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
|
||||
window.addEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
if (fallbackPollId !== null) window.clearInterval(fallbackPollId);
|
||||
source?.close();
|
||||
window.removeEventListener("focus", onVisible);
|
||||
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
|
||||
window.removeEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [activeSessionId, loadPersistedCards, loadWorkbench, refreshSessions, setSessions]);
|
||||
}
|
||||
|
||||
export function useCardsState(activeSessionIdRef: { current: string }) {
|
||||
const [cards, setCards] = useState<CardItem[]>([]);
|
||||
|
||||
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
|
||||
setCards((prev) => mergeCardItem(prev, item));
|
||||
}, []);
|
||||
|
||||
const dismissCard = useCallback((id: number) => {
|
||||
setCards((prev) => {
|
||||
const card = prev.find((entry) => entry.id === id);
|
||||
if (card?.serverId) {
|
||||
const url = BACKEND_URL
|
||||
? `${BACKEND_URL}/cards/${card.serverId}`
|
||||
: `/cards/${card.serverId}`;
|
||||
fetch(url, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
return prev.filter((entry) => entry.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadPersistedCards = useCallback(async () => {
|
||||
try {
|
||||
const rawCards = await fetchPersistedCardsFromBackend(activeSessionIdRef.current);
|
||||
if (!rawCards) return;
|
||||
setCards((prev) => reconcilePersistedCards(prev, rawCards));
|
||||
} catch (err) {
|
||||
console.warn("[cards] failed to load persisted cards", err);
|
||||
}
|
||||
}, [activeSessionIdRef]);
|
||||
|
||||
const clearCards = useCallback(() => {
|
||||
setCards([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onCardLiveContentChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
|
||||
const cardId =
|
||||
customEvent.detail && typeof customEvent.detail.cardId === "string"
|
||||
? customEvent.detail.cardId
|
||||
: "";
|
||||
setCards((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
|
||||
return sortCards([...prev]);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
|
||||
return () => {
|
||||
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { cards, upsertCard, dismissCard, loadPersistedCards, clearCards };
|
||||
}
|
||||
|
||||
export function useWorkbenchState(activeSessionIdRef: { current: string }) {
|
||||
const [workbenchItems, setWorkbenchItems] = useState<WorkbenchItem[]>([]);
|
||||
|
||||
const upsertWorkbench = useCallback((item: WorkbenchItem) => {
|
||||
setWorkbenchItems((prev) => mergeWorkbenchItem(prev, item));
|
||||
}, []);
|
||||
|
||||
const dismissWorkbenchItem = useCallback(
|
||||
async (id: string) => {
|
||||
const key = id.trim();
|
||||
if (!key) return;
|
||||
const chatId = activeSessionIdRef.current;
|
||||
const params = new URLSearchParams({ chat_id: chatId });
|
||||
const url = BACKEND_URL
|
||||
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}?${params}`
|
||||
: `/workbench/${encodeURIComponent(key)}?${params}`;
|
||||
await fetch(url, { method: "DELETE" }).catch(() => {});
|
||||
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
|
||||
},
|
||||
[activeSessionIdRef],
|
||||
);
|
||||
|
||||
const promoteWorkbenchItem = useCallback(
|
||||
async (id: string) => {
|
||||
const key = id.trim();
|
||||
if (!key) return;
|
||||
const chatId = activeSessionIdRef.current;
|
||||
const url = BACKEND_URL
|
||||
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}/promote`
|
||||
: `/workbench/${encodeURIComponent(key)}/promote`;
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: chatId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`promote failed (${resp.status})`);
|
||||
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
|
||||
window.dispatchEvent(new Event("nanobot:cards-refresh"));
|
||||
},
|
||||
[activeSessionIdRef],
|
||||
);
|
||||
|
||||
const loadWorkbench = useCallback(async () => {
|
||||
try {
|
||||
const rawItems = await fetchWorkbenchFromBackend(activeSessionIdRef.current);
|
||||
if (!rawItems) return;
|
||||
setWorkbenchItems((prev) => reconcilePersistedWorkbench(prev, rawItems));
|
||||
} catch (err) {
|
||||
console.warn("[workbench] failed to load workbench items", err);
|
||||
}
|
||||
}, [activeSessionIdRef]);
|
||||
|
||||
const clearWorkbench = useCallback(() => {
|
||||
setWorkbenchItems([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workbenchItems,
|
||||
upsertWorkbench,
|
||||
dismissWorkbenchItem,
|
||||
promoteWorkbenchItem,
|
||||
loadWorkbench,
|
||||
clearWorkbench,
|
||||
};
|
||||
}
|
||||
399
frontend/src/hooks/webrtc/messages.ts
Normal file
399
frontend/src/hooks/webrtc/messages.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import type {
|
||||
AgentState,
|
||||
CardItem,
|
||||
LogLine,
|
||||
ServerMessage,
|
||||
SessionHistoryLine,
|
||||
WorkbenchItem,
|
||||
} from "../../types";
|
||||
import { useCardsState, useWorkbenchState } from "./cards";
|
||||
import type { AppendLine, IdleFallbackControls, SetAgentState, UpsertCard } from "./types";
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
||||
function hydrateLogLines(entries: SessionHistoryLine[]): LogLine[] {
|
||||
return entries.map((entry) => ({
|
||||
id: logIdCounter++,
|
||||
role: entry.role,
|
||||
text: entry.text,
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
contextLabel: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function appendLogLineEntry(
|
||||
prev: LogLine[],
|
||||
role: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
contextLabel?: string,
|
||||
): LogLine[] {
|
||||
const next = [
|
||||
...prev,
|
||||
{
|
||||
id: logIdCounter++,
|
||||
role,
|
||||
text,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
contextLabel: contextLabel?.trim() || undefined,
|
||||
},
|
||||
];
|
||||
return next.length > 250 ? next.slice(next.length - 250) : next;
|
||||
}
|
||||
|
||||
function appendStreamingAssistantEntry(
|
||||
prev: LogLine[],
|
||||
text: string,
|
||||
timestamp: string,
|
||||
): LogLine[] {
|
||||
const nextTimestamp = timestamp || new Date().toISOString();
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === "nanobot-progress") {
|
||||
const next = [...prev];
|
||||
next[next.length - 1] = {
|
||||
...last,
|
||||
text: `${last.text}${text}`,
|
||||
timestamp: nextTimestamp,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return appendLogLineEntry(prev, "nanobot-progress", text, nextTimestamp);
|
||||
}
|
||||
|
||||
function finalizeStreamingAssistantEntry(
|
||||
prev: LogLine[],
|
||||
text: string,
|
||||
timestamp: string,
|
||||
): LogLine[] {
|
||||
const nextTimestamp = timestamp || new Date().toISOString();
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === "nanobot-progress") {
|
||||
const next = [...prev];
|
||||
next[next.length - 1] = {
|
||||
...last,
|
||||
role: "nanobot",
|
||||
text,
|
||||
timestamp: nextTimestamp,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return appendLogLineEntry(prev, "nanobot", text, nextTimestamp);
|
||||
}
|
||||
|
||||
interface MessageHandlers {
|
||||
setAgentState: SetAgentState;
|
||||
appendLine: AppendLine;
|
||||
appendAssistantDelta: AppendLine;
|
||||
finalizeAssistantLine: AppendLine;
|
||||
upsertCard: UpsertCard;
|
||||
upsertWorkbench: (item: WorkbenchItem) => void;
|
||||
idleFallback: IdleFallbackControls;
|
||||
}
|
||||
|
||||
function handleAgentStateMessage(
|
||||
msg: Extract<ServerMessage, { type: "agent_state" }>,
|
||||
handlers: Pick<MessageHandlers, "idleFallback" | "setAgentState">,
|
||||
): void {
|
||||
handlers.idleFallback.clear();
|
||||
handlers.setAgentState(() => msg.state);
|
||||
}
|
||||
|
||||
function handleTextMessage(
|
||||
msg: Extract<ServerMessage, { type: "message" }>,
|
||||
handlers: Pick<
|
||||
MessageHandlers,
|
||||
"appendAssistantDelta" | "appendLine" | "finalizeAssistantLine" | "idleFallback"
|
||||
>,
|
||||
): void {
|
||||
if (msg.is_tool_hint) {
|
||||
handlers.appendLine("tool", msg.content, msg.timestamp);
|
||||
return;
|
||||
}
|
||||
if (msg.is_progress) {
|
||||
handlers.appendAssistantDelta("nanobot-progress", msg.content, msg.timestamp);
|
||||
return;
|
||||
}
|
||||
handlers.finalizeAssistantLine(msg.role, msg.content, msg.timestamp, msg.context_label);
|
||||
handlers.idleFallback.schedule();
|
||||
}
|
||||
|
||||
function toIncomingCard(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
|
||||
return {
|
||||
serverId: msg.id,
|
||||
kind: msg.kind,
|
||||
content: msg.content,
|
||||
title: msg.title,
|
||||
question: msg.question || undefined,
|
||||
choices: msg.choices.length > 0 ? msg.choices : undefined,
|
||||
responseValue: msg.response_value || undefined,
|
||||
slot: msg.slot || undefined,
|
||||
lane: msg.lane,
|
||||
priority: msg.priority,
|
||||
state: msg.state,
|
||||
templateKey: msg.template_key || undefined,
|
||||
templateState:
|
||||
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
|
||||
contextSummary: msg.context_summary || undefined,
|
||||
createdAt: msg.created_at || new Date().toISOString(),
|
||||
updatedAt: msg.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toIncomingWorkbench(msg: Extract<ServerMessage, { type: "workbench" }>): WorkbenchItem {
|
||||
return {
|
||||
id: msg.id,
|
||||
chatId: msg.chat_id,
|
||||
kind: msg.kind,
|
||||
title: msg.title,
|
||||
content: msg.content,
|
||||
question: msg.question || undefined,
|
||||
choices: msg.choices.length > 0 ? msg.choices : undefined,
|
||||
responseValue: msg.response_value || undefined,
|
||||
slot: msg.slot || undefined,
|
||||
templateKey: msg.template_key || undefined,
|
||||
templateState:
|
||||
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
|
||||
contextSummary: msg.context_summary || undefined,
|
||||
promotable: msg.promotable !== false,
|
||||
sourceCardId: msg.source_card_id || undefined,
|
||||
createdAt: msg.created_at || new Date().toISOString(),
|
||||
updatedAt: msg.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function handleTypedMessage(
|
||||
msg: Extract<ServerMessage, { type: string }>,
|
||||
handlers: MessageHandlers,
|
||||
): void {
|
||||
switch (msg.type) {
|
||||
case "agent_state":
|
||||
handleAgentStateMessage(msg, handlers);
|
||||
return;
|
||||
case "message":
|
||||
handleTextMessage(msg, handlers);
|
||||
return;
|
||||
case "card":
|
||||
handlers.upsertCard(toIncomingCard(msg));
|
||||
handlers.idleFallback.schedule();
|
||||
return;
|
||||
case "workbench":
|
||||
handlers.upsertWorkbench(toIncomingWorkbench(msg));
|
||||
handlers.idleFallback.schedule();
|
||||
return;
|
||||
case "error":
|
||||
handlers.appendLine("system", msg.error, "");
|
||||
handlers.idleFallback.schedule();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
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, contextLabel?: string) => {
|
||||
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const appendAssistantDelta = useCallback((_: string, text: string, timestamp: string) => {
|
||||
setLogLines((prev) => appendStreamingAssistantEntry(prev, text, timestamp));
|
||||
}, []);
|
||||
|
||||
const finalizeAssistantLine = useCallback(
|
||||
(role: string, text: string, timestamp: string, contextLabel?: string) => {
|
||||
if (role !== "nanobot") {
|
||||
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
|
||||
return;
|
||||
}
|
||||
setLogLines((prev) => finalizeStreamingAssistantEntry(prev, text, timestamp));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const replaceLines = useCallback((entries: SessionHistoryLine[]) => {
|
||||
setLogLines(hydrateLogLines(entries));
|
||||
}, []);
|
||||
|
||||
const clearLines = useCallback(() => {
|
||||
setLogLines([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
logLines,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
replaceLines,
|
||||
clearLines,
|
||||
};
|
||||
}
|
||||
|
||||
function useIncomingMessages({
|
||||
setAgentState,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
idleFallback,
|
||||
}: {
|
||||
setAgentState: SetAgentState;
|
||||
appendLine: AppendLine;
|
||||
appendAssistantDelta: AppendLine;
|
||||
finalizeAssistantLine: AppendLine;
|
||||
upsertCard: UpsertCard;
|
||||
upsertWorkbench: (item: WorkbenchItem) => void;
|
||||
idleFallback: IdleFallbackControls;
|
||||
}) {
|
||||
return useCallback(
|
||||
(raw: string) => {
|
||||
const msg = parseServerMessage(raw);
|
||||
if (!msg) return;
|
||||
handleTypedMessage(msg, {
|
||||
setAgentState,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
idleFallback,
|
||||
});
|
||||
},
|
||||
[
|
||||
appendAssistantDelta,
|
||||
appendLine,
|
||||
finalizeAssistantLine,
|
||||
idleFallback,
|
||||
setAgentState,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function useMessageState(activeSessionIdRef: { current: string }) {
|
||||
const [agentState, setAgentState] = useState<AgentState>("idle");
|
||||
const {
|
||||
logLines,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
replaceLines,
|
||||
clearLines,
|
||||
} = useLogState();
|
||||
const { cards, upsertCard, dismissCard, loadPersistedCards, clearCards } =
|
||||
useCardsState(activeSessionIdRef);
|
||||
const {
|
||||
workbenchItems,
|
||||
upsertWorkbench,
|
||||
dismissWorkbenchItem,
|
||||
promoteWorkbenchItem,
|
||||
loadWorkbench,
|
||||
clearWorkbench,
|
||||
} = useWorkbenchState(activeSessionIdRef);
|
||||
const idleFallback = useIdleFallback(setAgentState);
|
||||
const suppressRtcTextRef = useRef(0);
|
||||
const onIncomingMessage = useIncomingMessages({
|
||||
setAgentState,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
idleFallback,
|
||||
});
|
||||
const onDcMessage = useCallback(
|
||||
(raw: string) => {
|
||||
const msg = parseServerMessage(raw);
|
||||
if (!msg) return;
|
||||
if (
|
||||
suppressRtcTextRef.current > 0 &&
|
||||
(msg.type === "message" || msg.type === "agent_state" || msg.type === "card")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleTypedMessage(msg, {
|
||||
setAgentState,
|
||||
appendLine,
|
||||
appendAssistantDelta,
|
||||
finalizeAssistantLine,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
idleFallback,
|
||||
});
|
||||
},
|
||||
[
|
||||
appendAssistantDelta,
|
||||
appendLine,
|
||||
finalizeAssistantLine,
|
||||
idleFallback,
|
||||
setAgentState,
|
||||
upsertCard,
|
||||
upsertWorkbench,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
agentState,
|
||||
setAgentState,
|
||||
logLines,
|
||||
cards,
|
||||
workbenchItems,
|
||||
appendLine,
|
||||
replaceLines,
|
||||
clearLines,
|
||||
clearCards,
|
||||
clearWorkbench,
|
||||
dismissCard,
|
||||
dismissWorkbenchItem,
|
||||
promoteWorkbenchItem,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
onDcMessage,
|
||||
onIncomingMessage,
|
||||
suppressRtcTextRef,
|
||||
};
|
||||
}
|
||||
319
frontend/src/hooks/webrtc/rtcTransport.ts
Normal file
319
frontend/src/hooks/webrtc/rtcTransport.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
import type { ClientMessage } from "../../types";
|
||||
import { BACKEND_URL } from "./backend";
|
||||
import type { AppendLine, RTCCallbacks, RTCRefs } from "./types";
|
||||
|
||||
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
|
||||
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireMicStream(): Promise<MediaStream> {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
} catch {
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
}
|
||||
}
|
||||
|
||||
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
pc.removeEventListener("icegatheringstatechange", check);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener("icegatheringstatechange", check);
|
||||
setTimeout(() => {
|
||||
pc.removeEventListener("icegatheringstatechange", check);
|
||||
resolve();
|
||||
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeSdp(
|
||||
localDesc: RTCSessionDescription,
|
||||
): Promise<{ sdp: string; rtcType: string }> {
|
||||
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
|
||||
const resp = await fetch(rtcUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
|
||||
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
|
||||
}
|
||||
|
||||
async function runConnect(
|
||||
refs: RTCRefs,
|
||||
cbs: RTCCallbacks,
|
||||
opts: { textOnly: boolean },
|
||||
): Promise<void> {
|
||||
if (refs.pcRef.current) return;
|
||||
if (!window.RTCPeerConnection) {
|
||||
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
|
||||
return;
|
||||
}
|
||||
cbs.setConnecting(true);
|
||||
cbs.showStatus("Connecting...");
|
||||
|
||||
let micStream: MediaStream | null = null;
|
||||
try {
|
||||
refs.localTracksRef.current = [];
|
||||
if (!opts.textOnly) {
|
||||
micStream = await acquireMicStream();
|
||||
const audioTracks = micStream.getAudioTracks();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = false;
|
||||
});
|
||||
refs.localTracksRef.current = audioTracks;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(
|
||||
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
|
||||
);
|
||||
refs.pcRef.current = pc;
|
||||
|
||||
const newRemoteStream = new MediaStream();
|
||||
cbs.setRemoteStream(newRemoteStream);
|
||||
if (refs.remoteAudioRef.current) {
|
||||
refs.remoteAudioRef.current.srcObject = newRemoteStream;
|
||||
refs.remoteAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
if (event.track.kind !== "audio") return;
|
||||
newRemoteStream.addTrack(event.track);
|
||||
refs.remoteAudioRef.current?.play().catch(() => {});
|
||||
};
|
||||
|
||||
const dc = pc.createDataChannel("app", { ordered: true });
|
||||
refs.dcRef.current = dc;
|
||||
dc.onopen = () => {
|
||||
cbs.setConnected(true);
|
||||
cbs.setConnecting(false);
|
||||
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
|
||||
cbs.appendLine("system", "Connected.", new Date().toISOString());
|
||||
cbs.onDcOpen();
|
||||
};
|
||||
dc.onclose = () => {
|
||||
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
|
||||
cbs.closePC();
|
||||
};
|
||||
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
|
||||
|
||||
refs.micSendersRef.current = [];
|
||||
if (micStream) {
|
||||
micStream.getAudioTracks().forEach((track) => {
|
||||
pc.addTrack(track, micStream as MediaStream);
|
||||
});
|
||||
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceComplete(pc);
|
||||
|
||||
const localDesc = pc.localDescription;
|
||||
if (!localDesc) throw new Error("No local description after ICE gathering");
|
||||
const answer = await exchangeSdp(localDesc);
|
||||
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
|
||||
} catch (err) {
|
||||
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
|
||||
cbs.showStatus("Connection failed.", 3000);
|
||||
cbs.closePC();
|
||||
}
|
||||
}
|
||||
|
||||
export function useRtcRefs(): RTCRefs {
|
||||
return {
|
||||
pcRef: useRef<RTCPeerConnection | null>(null),
|
||||
dcRef: useRef<RTCDataChannel | null>(null),
|
||||
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
|
||||
micSendersRef: useRef<RTCRtpSender[]>([]),
|
||||
localTracksRef: useRef<MediaStreamTrack[]>([]),
|
||||
};
|
||||
}
|
||||
|
||||
export function useRemoteAudioBindings({
|
||||
textOnly,
|
||||
connected,
|
||||
showStatus,
|
||||
remoteAudioRef,
|
||||
micSendersRef,
|
||||
dcRef,
|
||||
textOnlyRef,
|
||||
}: {
|
||||
textOnly: boolean;
|
||||
connected: boolean;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
remoteAudioRef: { current: HTMLAudioElement | null };
|
||||
micSendersRef: { current: RTCRtpSender[] };
|
||||
dcRef: { current: RTCDataChannel | null };
|
||||
textOnlyRef: { current: boolean };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
textOnlyRef.current = textOnly;
|
||||
}, [textOnly, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
|
||||
audio.muted = textOnlyRef.current;
|
||||
remoteAudioRef.current = audio;
|
||||
return () => {
|
||||
audio.srcObject = null;
|
||||
};
|
||||
}, [remoteAudioRef, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
|
||||
micSendersRef.current.forEach((sender) => {
|
||||
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
|
||||
});
|
||||
};
|
||||
window.addEventListener("nanobot-mic-enable", handler);
|
||||
return () => window.removeEventListener("nanobot-mic-enable", handler);
|
||||
}, [micSendersRef, textOnlyRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteAudioRef.current) {
|
||||
remoteAudioRef.current.muted = textOnly;
|
||||
if (textOnly) remoteAudioRef.current.pause();
|
||||
else remoteAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
micSendersRef.current.forEach((sender) => {
|
||||
if (sender.track) sender.track.enabled = false;
|
||||
});
|
||||
if (textOnly) {
|
||||
const dc = dcRef.current;
|
||||
if (dc?.readyState === "open") {
|
||||
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
|
||||
}
|
||||
}
|
||||
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
|
||||
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
|
||||
}
|
||||
|
||||
export function usePeerConnectionControls({
|
||||
textOnly,
|
||||
connected,
|
||||
appendLine,
|
||||
onDcMessage,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
showStatus,
|
||||
refs,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
textOnlyRef,
|
||||
}: {
|
||||
textOnly: boolean;
|
||||
connected: boolean;
|
||||
appendLine: AppendLine;
|
||||
onDcMessage: (raw: string) => void;
|
||||
loadPersistedCards: () => Promise<void>;
|
||||
loadWorkbench: () => Promise<void>;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
refs: RTCRefs;
|
||||
setConnected: (value: boolean) => void;
|
||||
setConnecting: (value: boolean) => void;
|
||||
setRemoteStream: (stream: MediaStream | null) => void;
|
||||
textOnlyRef: { current: boolean };
|
||||
}) {
|
||||
const closePC = useCallback(() => {
|
||||
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 = 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(
|
||||
refs,
|
||||
{
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
showStatus,
|
||||
appendLine,
|
||||
onDcMessage,
|
||||
onDcOpen: () => {
|
||||
loadPersistedCards().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
},
|
||||
closePC,
|
||||
},
|
||||
{ textOnly: textOnlyRef.current },
|
||||
);
|
||||
}, [
|
||||
appendLine,
|
||||
closePC,
|
||||
loadPersistedCards,
|
||||
loadWorkbench,
|
||||
onDcMessage,
|
||||
refs,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setRemoteStream,
|
||||
showStatus,
|
||||
textOnlyRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
|
||||
closePC();
|
||||
connect().catch(() => {});
|
||||
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closePCRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { closePC, connect };
|
||||
}
|
||||
87
frontend/src/hooks/webrtc/types.ts
Normal file
87
frontend/src/hooks/webrtc/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type {
|
||||
AgentState,
|
||||
CardItem,
|
||||
CardMessageMetadata,
|
||||
ClientMessage,
|
||||
LogLine,
|
||||
ServerMessage,
|
||||
SessionHistoryLine,
|
||||
SessionSummary,
|
||||
WorkbenchItem,
|
||||
} from "../../types";
|
||||
|
||||
export interface WebRTCState {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
textOnly: boolean;
|
||||
agentState: AgentState;
|
||||
logLines: LogLine[];
|
||||
cards: CardItem[];
|
||||
workbenchItems: WorkbenchItem[];
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string;
|
||||
activeSession: SessionSummary | null;
|
||||
sessionLoading: boolean;
|
||||
textStreaming: boolean;
|
||||
voiceStatus: string;
|
||||
statusVisible: boolean;
|
||||
remoteAudioEl: HTMLAudioElement | null;
|
||||
remoteStream: MediaStream | null;
|
||||
sendJson(msg: ClientMessage): void;
|
||||
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
|
||||
dismissCard(id: number): void;
|
||||
dismissWorkbenchItem(id: string): Promise<void>;
|
||||
promoteWorkbenchItem(id: string): Promise<void>;
|
||||
setTextOnly(enabled: boolean): void;
|
||||
switchSession(chatId: string): Promise<void>;
|
||||
createSession(title?: string): Promise<void>;
|
||||
renameSession(chatId: string, title: string): Promise<void>;
|
||||
deleteSession(chatId: string): Promise<void>;
|
||||
connect(): Promise<void>;
|
||||
}
|
||||
|
||||
export type AppendLine = (
|
||||
role: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
contextLabel?: string,
|
||||
) => void;
|
||||
export type UpsertCard = (item: Omit<CardItem, "id">) => void;
|
||||
export type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
|
||||
export type RawPersistedCard =
|
||||
| Extract<ServerMessage, { type: "card" }>
|
||||
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
|
||||
export type RawWorkbenchItem =
|
||||
| Extract<ServerMessage, { type: "workbench" }>
|
||||
| (Omit<Extract<ServerMessage, { type: "workbench" }>, "type"> & {
|
||||
type?: "workbench";
|
||||
});
|
||||
|
||||
export interface IdleFallbackControls {
|
||||
clear(): void;
|
||||
schedule(delayMs?: number): void;
|
||||
}
|
||||
|
||||
export interface RTCRefs {
|
||||
pcRef: { current: RTCPeerConnection | null };
|
||||
dcRef: { current: RTCDataChannel | null };
|
||||
remoteAudioRef: { current: HTMLAudioElement | null };
|
||||
micSendersRef: { current: RTCRtpSender[] };
|
||||
localTracksRef: { current: MediaStreamTrack[] };
|
||||
}
|
||||
|
||||
export interface RTCCallbacks {
|
||||
setConnected: (v: boolean) => void;
|
||||
setConnecting: (v: boolean) => void;
|
||||
setRemoteStream: (s: MediaStream | null) => void;
|
||||
showStatus: (text: string, persistMs?: number) => void;
|
||||
appendLine: AppendLine;
|
||||
onDcMessage: (raw: string) => void;
|
||||
onDcOpen: () => void;
|
||||
closePC: () => void;
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
session: SessionSummary;
|
||||
messages: SessionHistoryLine[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
49
frontend/src/lib/sse.ts
Normal file
49
frontend/src/lib/sse.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export async function streamSseResponse(
|
||||
response: Response,
|
||||
onMessage: (raw: string) => void,
|
||||
): Promise<void> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventData = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
||||
({ buffer, eventData } = consumeSseBuffer(buffer, eventData, onMessage));
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
const tail = `${eventData}\n${buffer}`.trim();
|
||||
if (tail) onMessage(tail);
|
||||
}
|
||||
|
||||
function consumeSseBuffer(
|
||||
rawBuffer: string,
|
||||
rawEventData: string,
|
||||
onMessage: (raw: string) => void,
|
||||
): { buffer: string; eventData: string } {
|
||||
let buffer = rawBuffer;
|
||||
let eventData = rawEventData;
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
eventData = consumeSseLine(line, eventData, onMessage);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
return { buffer, eventData };
|
||||
}
|
||||
|
||||
function consumeSseLine(line: string, eventData: string, onMessage: (raw: string) => void): string {
|
||||
if (!line) {
|
||||
const payload = eventData.trim();
|
||||
if (payload) onMessage(payload);
|
||||
return "";
|
||||
}
|
||||
if (!line.startsWith("data:")) return eventData;
|
||||
const chunk = line.slice(5).trimStart();
|
||||
return eventData ? `${eventData}\n${chunk}` : chunk;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { render } from "preact";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
import { applyThemeToDocument, getStoredThemeName } from "./theme/themes";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
applyThemeToDocument(getStoredThemeName());
|
||||
if (root) render(<App />, root);
|
||||
|
|
|
|||
252
frontend/src/theme/themes.ts
Normal file
252
frontend/src/theme/themes.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
export type ThemeName = "clay" | "sage" | "mist";
|
||||
|
||||
export interface ThemeOption {
|
||||
name: ThemeName;
|
||||
label: string;
|
||||
swatch: string;
|
||||
tokens: Record<string, string>;
|
||||
}
|
||||
|
||||
export const THEME_STORAGE_KEY = "nanobot.theme";
|
||||
export const DEFAULT_THEME: ThemeName = "clay";
|
||||
|
||||
const cardFonts = {
|
||||
"--card-font": '"Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
|
||||
"--theme-font-title": '"IBM Plex Sans Condensed", "SF Pro Display", "Arial Narrow", sans-serif',
|
||||
"--theme-font-mono": '"M-1m Code", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
|
||||
};
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
name: "clay",
|
||||
label: "Clay",
|
||||
swatch: "linear-gradient(135deg, #b56c3d 0%, #d9b1b8 100%)",
|
||||
tokens: {
|
||||
...cardFonts,
|
||||
"--theme-root-bg": "radial-gradient(circle at center, #f7d9bf 0%, #f2caa8 100%)",
|
||||
"--theme-agent-bg": "#ffffff",
|
||||
"--theme-agent-text-bg":
|
||||
"radial-gradient(circle at top, rgba(245, 228, 210, 0.54), transparent 34%), linear-gradient(180deg, #fcf7f1 0%, #f6efe7 100%)",
|
||||
"--theme-feed-bg": "#e7ddd0",
|
||||
"--theme-panel-bg": "rgba(247, 239, 230, 0.9)",
|
||||
"--theme-panel-bg-strong": "rgba(250, 244, 237, 0.98)",
|
||||
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.94)",
|
||||
"--theme-border": "rgba(141, 104, 75, 0.18)",
|
||||
"--theme-border-strong": "rgba(120, 84, 52, 0.22)",
|
||||
"--theme-text": "#2f2118",
|
||||
"--theme-text-soft": "#5f4634",
|
||||
"--theme-text-muted": "#8b654b",
|
||||
"--theme-accent": "#b56c3d",
|
||||
"--theme-accent-strong": "#99532a",
|
||||
"--theme-accent-soft": "rgba(255, 200, 140, 0.2)",
|
||||
"--theme-accent-contrast": "#ffffff",
|
||||
"--theme-overlay": "rgba(0, 0, 0, 0.42)",
|
||||
"--theme-shadow": "0 8px 18px rgba(48, 28, 18, 0.1)",
|
||||
"--theme-shadow-strong": "0 18px 36px rgba(48, 28, 18, 0.18)",
|
||||
"--theme-status-muted": "#6b7280",
|
||||
"--theme-status-live": "#047857",
|
||||
"--theme-status-warning": "#b45309",
|
||||
"--theme-status-danger": "#b91c1c",
|
||||
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)",
|
||||
"--theme-card-neutral-border": "#dbe4ee",
|
||||
"--theme-card-neutral-text": "#111827",
|
||||
"--theme-card-neutral-muted": "#64748b",
|
||||
"--theme-card-neutral-subtle": "#475569",
|
||||
"--theme-card-warm-bg": "linear-gradient(180deg, #fffdfb 0%, #f7f1ea 100%)",
|
||||
"--theme-card-warm-border": "rgba(168, 120, 86, 0.18)",
|
||||
"--theme-card-warm-text": "#23150d",
|
||||
"--theme-card-warm-muted": "#78543b",
|
||||
"--theme-card-success-bg":
|
||||
"linear-gradient(180deg, rgba(234, 244, 229, 0.98), rgba(218, 232, 212, 0.98))",
|
||||
"--theme-card-success-border": "rgba(96, 126, 90, 0.18)",
|
||||
"--theme-card-success-text": "#2f442a",
|
||||
"--theme-card-success-muted": "rgba(52, 82, 45, 0.76)",
|
||||
"--theme-task-bg":
|
||||
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(253, 245, 235, 0.98), rgba(242, 227, 211, 0.97))",
|
||||
"--theme-task-border": "rgba(87, 65, 50, 0.14)",
|
||||
"--theme-task-text": "#2f241e",
|
||||
"--theme-task-muted": "#7e6659",
|
||||
"--theme-task-shadow":
|
||||
"inset 0 1px 0 rgba(255, 255, 255, 0.68), 0 18px 36px rgba(79, 56, 43, 0.12)",
|
||||
"--theme-task-pattern": "rgba(122, 97, 78, 0.035)",
|
||||
"--theme-list-bg":
|
||||
"radial-gradient(circle at top right, rgba(255, 252, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(244, 226, 187, 0.98), rgba(226, 198, 145, 0.97))",
|
||||
"--theme-list-text": "#4d392d",
|
||||
"--theme-list-muted": "rgba(77, 57, 45, 0.72)",
|
||||
"--theme-list-border": "rgba(92, 70, 55, 0.18)",
|
||||
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 224, 0.62)",
|
||||
"--card-surface": "linear-gradient(180deg, #b56c3d 0%, #8f4f27 100%)",
|
||||
"--card-border": "rgba(255, 220, 188, 0.24)",
|
||||
"--card-shadow": "0 10px 28px rgba(68, 34, 15, 0.22)",
|
||||
"--card-text": "rgba(255, 245, 235, 0.9)",
|
||||
"--card-muted": "rgba(255, 233, 214, 0.72)",
|
||||
"--helper-card-surface": "linear-gradient(180deg, #d9b1b8 0%, #c78d9a 100%)",
|
||||
"--helper-card-border": "rgba(111, 46, 63, 0.18)",
|
||||
"--helper-card-shadow": "0 10px 28px rgba(92, 42, 57, 0.16)",
|
||||
"--helper-card-text": "rgba(63, 23, 35, 0.94)",
|
||||
"--helper-card-muted": "rgba(87, 43, 56, 0.72)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sage",
|
||||
label: "Sage",
|
||||
swatch: "linear-gradient(135deg, #6d8a5d 0%, #d7cfb4 100%)",
|
||||
tokens: {
|
||||
...cardFonts,
|
||||
"--theme-root-bg": "radial-gradient(circle at center, #dfe8d3 0%, #c8d5bf 100%)",
|
||||
"--theme-agent-bg": "#fbfcf8",
|
||||
"--theme-agent-text-bg":
|
||||
"radial-gradient(circle at top, rgba(232, 242, 223, 0.54), transparent 34%), linear-gradient(180deg, #fbfcf8 0%, #f1f5eb 100%)",
|
||||
"--theme-feed-bg": "#dfe6d7",
|
||||
"--theme-panel-bg": "rgba(241, 246, 236, 0.92)",
|
||||
"--theme-panel-bg-strong": "rgba(247, 250, 243, 0.98)",
|
||||
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
|
||||
"--theme-border": "rgba(107, 129, 95, 0.18)",
|
||||
"--theme-border-strong": "rgba(86, 110, 74, 0.24)",
|
||||
"--theme-text": "#263126",
|
||||
"--theme-text-soft": "#415141",
|
||||
"--theme-text-muted": "#677767",
|
||||
"--theme-accent": "#6d8a5d",
|
||||
"--theme-accent-strong": "#547246",
|
||||
"--theme-accent-soft": "rgba(137, 170, 117, 0.18)",
|
||||
"--theme-accent-contrast": "#ffffff",
|
||||
"--theme-overlay": "rgba(24, 34, 24, 0.34)",
|
||||
"--theme-shadow": "0 8px 18px rgba(40, 60, 36, 0.12)",
|
||||
"--theme-shadow-strong": "0 18px 36px rgba(40, 60, 36, 0.16)",
|
||||
"--theme-status-muted": "#5f6b5f",
|
||||
"--theme-status-live": "#2f6b47",
|
||||
"--theme-status-warning": "#9a641d",
|
||||
"--theme-status-danger": "#a13f35",
|
||||
"--theme-card-neutral-bg": "linear-gradient(180deg, #fcfdf9 0%, #eff4e8 100%)",
|
||||
"--theme-card-neutral-border": "rgba(122, 145, 109, 0.22)",
|
||||
"--theme-card-neutral-text": "#223022",
|
||||
"--theme-card-neutral-muted": "#607260",
|
||||
"--theme-card-neutral-subtle": "#4b5f4b",
|
||||
"--theme-card-warm-bg": "linear-gradient(180deg, #f8f7ef 0%, #ece7d8 100%)",
|
||||
"--theme-card-warm-border": "rgba(150, 134, 98, 0.22)",
|
||||
"--theme-card-warm-text": "#353024",
|
||||
"--theme-card-warm-muted": "#776950",
|
||||
"--theme-card-success-bg":
|
||||
"linear-gradient(180deg, rgba(229, 241, 220, 0.98), rgba(211, 229, 201, 0.98))",
|
||||
"--theme-card-success-border": "rgba(98, 129, 86, 0.2)",
|
||||
"--theme-card-success-text": "#29412a",
|
||||
"--theme-card-success-muted": "rgba(56, 82, 49, 0.76)",
|
||||
"--theme-task-bg":
|
||||
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.68), transparent 32%), linear-gradient(145deg, rgba(248, 250, 240, 0.98), rgba(231, 236, 219, 0.97))",
|
||||
"--theme-task-border": "rgba(95, 118, 89, 0.14)",
|
||||
"--theme-task-text": "#243125",
|
||||
"--theme-task-muted": "#647464",
|
||||
"--theme-task-shadow":
|
||||
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(54, 72, 48, 0.12)",
|
||||
"--theme-task-pattern": "rgba(98, 118, 90, 0.03)",
|
||||
"--theme-list-bg":
|
||||
"radial-gradient(circle at top right, rgba(255, 249, 227, 0.68), transparent 34%), linear-gradient(145deg, rgba(238, 232, 190, 0.98), rgba(221, 213, 160, 0.97))",
|
||||
"--theme-list-text": "#4f4531",
|
||||
"--theme-list-muted": "rgba(79, 69, 49, 0.7)",
|
||||
"--theme-list-border": "rgba(105, 93, 67, 0.18)",
|
||||
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 251, 232, 0.62)",
|
||||
"--card-surface": "linear-gradient(180deg, #7a8f63 0%, #5d714b 100%)",
|
||||
"--card-border": "rgba(231, 243, 219, 0.24)",
|
||||
"--card-shadow": "0 10px 28px rgba(44, 58, 33, 0.22)",
|
||||
"--card-text": "rgba(247, 250, 241, 0.92)",
|
||||
"--card-muted": "rgba(226, 236, 210, 0.76)",
|
||||
"--helper-card-surface": "linear-gradient(180deg, #d5c3cc 0%, #b898a8 100%)",
|
||||
"--helper-card-border": "rgba(100, 72, 84, 0.18)",
|
||||
"--helper-card-shadow": "0 10px 28px rgba(76, 54, 64, 0.16)",
|
||||
"--helper-card-text": "rgba(49, 36, 41, 0.94)",
|
||||
"--helper-card-muted": "rgba(83, 63, 71, 0.72)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mist",
|
||||
label: "Mist",
|
||||
swatch: "linear-gradient(135deg, #5f7884 0%, #c7d2dd 100%)",
|
||||
tokens: {
|
||||
...cardFonts,
|
||||
"--theme-root-bg": "radial-gradient(circle at center, #d8e0e7 0%, #c1ccd8 100%)",
|
||||
"--theme-agent-bg": "#f8fafc",
|
||||
"--theme-agent-text-bg":
|
||||
"radial-gradient(circle at top, rgba(225, 233, 241, 0.56), transparent 34%), linear-gradient(180deg, #fbfcfe 0%, #eef3f7 100%)",
|
||||
"--theme-feed-bg": "#dce3ea",
|
||||
"--theme-panel-bg": "rgba(239, 244, 248, 0.92)",
|
||||
"--theme-panel-bg-strong": "rgba(247, 250, 252, 0.98)",
|
||||
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
|
||||
"--theme-border": "rgba(96, 117, 131, 0.18)",
|
||||
"--theme-border-strong": "rgba(86, 104, 118, 0.24)",
|
||||
"--theme-text": "#23303a",
|
||||
"--theme-text-soft": "#42515f",
|
||||
"--theme-text-muted": "#6a7b88",
|
||||
"--theme-accent": "#5f7884",
|
||||
"--theme-accent-strong": "#46616d",
|
||||
"--theme-accent-soft": "rgba(120, 146, 160, 0.18)",
|
||||
"--theme-accent-contrast": "#ffffff",
|
||||
"--theme-overlay": "rgba(18, 28, 36, 0.36)",
|
||||
"--theme-shadow": "0 8px 18px rgba(34, 48, 58, 0.12)",
|
||||
"--theme-shadow-strong": "0 18px 36px rgba(34, 48, 58, 0.18)",
|
||||
"--theme-status-muted": "#5f6b75",
|
||||
"--theme-status-live": "#16616c",
|
||||
"--theme-status-warning": "#9a641d",
|
||||
"--theme-status-danger": "#a13f35",
|
||||
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #eef4f8 100%)",
|
||||
"--theme-card-neutral-border": "rgba(137, 160, 175, 0.22)",
|
||||
"--theme-card-neutral-text": "#1e2934",
|
||||
"--theme-card-neutral-muted": "#657786",
|
||||
"--theme-card-neutral-subtle": "#506271",
|
||||
"--theme-card-warm-bg": "linear-gradient(180deg, #fcfcfb 0%, #eef1ed 100%)",
|
||||
"--theme-card-warm-border": "rgba(132, 146, 129, 0.2)",
|
||||
"--theme-card-warm-text": "#283127",
|
||||
"--theme-card-warm-muted": "#667262",
|
||||
"--theme-card-success-bg":
|
||||
"linear-gradient(180deg, rgba(231, 240, 236, 0.98), rgba(213, 227, 220, 0.98))",
|
||||
"--theme-card-success-border": "rgba(102, 132, 118, 0.2)",
|
||||
"--theme-card-success-text": "#274036",
|
||||
"--theme-card-success-muted": "rgba(56, 82, 71, 0.76)",
|
||||
"--theme-task-bg":
|
||||
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(244, 248, 250, 0.98), rgba(226, 233, 238, 0.97))",
|
||||
"--theme-task-border": "rgba(98, 117, 130, 0.14)",
|
||||
"--theme-task-text": "#23303a",
|
||||
"--theme-task-muted": "#657684",
|
||||
"--theme-task-shadow":
|
||||
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(56, 72, 84, 0.12)",
|
||||
"--theme-task-pattern": "rgba(102, 122, 136, 0.03)",
|
||||
"--theme-list-bg":
|
||||
"radial-gradient(circle at top right, rgba(249, 248, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(236, 230, 198, 0.98), rgba(221, 214, 173, 0.97))",
|
||||
"--theme-list-text": "#4b4334",
|
||||
"--theme-list-muted": "rgba(75, 67, 52, 0.72)",
|
||||
"--theme-list-border": "rgba(100, 92, 73, 0.18)",
|
||||
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 234, 0.62)",
|
||||
"--card-surface": "linear-gradient(180deg, #6d8391 0%, #526774 100%)",
|
||||
"--card-border": "rgba(228, 236, 241, 0.24)",
|
||||
"--card-shadow": "0 10px 28px rgba(35, 49, 58, 0.22)",
|
||||
"--card-text": "rgba(244, 248, 250, 0.92)",
|
||||
"--card-muted": "rgba(220, 229, 234, 0.76)",
|
||||
"--helper-card-surface": "linear-gradient(180deg, #d0bfd0 0%, #b49cb6 100%)",
|
||||
"--helper-card-border": "rgba(91, 73, 96, 0.18)",
|
||||
"--helper-card-shadow": "0 10px 28px rgba(71, 55, 76, 0.16)",
|
||||
"--helper-card-text": "rgba(43, 35, 47, 0.94)",
|
||||
"--helper-card-muted": "rgba(77, 63, 83, 0.72)",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getTheme(themeName: string | null | undefined): ThemeOption {
|
||||
return THEME_OPTIONS.find((theme) => theme.name === themeName) || THEME_OPTIONS[0];
|
||||
}
|
||||
|
||||
export function getStoredThemeName(): ThemeName {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const theme = getTheme(stored);
|
||||
return theme.name;
|
||||
}
|
||||
|
||||
export function applyThemeToDocument(themeName: ThemeName): ThemeOption {
|
||||
const theme = getTheme(themeName);
|
||||
if (typeof document === "undefined") return theme;
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = theme.name;
|
||||
for (const [key, value] of Object.entries(theme.tokens)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
30
frontend/src/theme/useThemePreference.ts
Normal file
30
frontend/src/theme/useThemePreference.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
DEFAULT_THEME,
|
||||
getStoredThemeName,
|
||||
THEME_OPTIONS,
|
||||
THEME_STORAGE_KEY,
|
||||
type ThemeName,
|
||||
} from "./themes";
|
||||
|
||||
export function useThemePreference() {
|
||||
const [themeName, setThemeName] = useState<ThemeName>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
return getStoredThemeName();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeToDocument(themeName);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themeName);
|
||||
}, [themeName]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
themeName,
|
||||
themeOptions: THEME_OPTIONS,
|
||||
setThemeName,
|
||||
}),
|
||||
[themeName],
|
||||
);
|
||||
}
|
||||
|
|
@ -34,6 +34,23 @@ export interface CardMessageMetadata {
|
|||
card_selection_label?: string;
|
||||
card_selection?: JsonValue;
|
||||
card_live_content?: JsonValue;
|
||||
context_label?: string;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
chat_id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface SessionHistoryLine {
|
||||
role: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
|
|
@ -45,6 +62,7 @@ export type ServerMessage =
|
|||
is_progress: boolean;
|
||||
is_tool_hint: boolean;
|
||||
timestamp: string;
|
||||
context_label?: string;
|
||||
}
|
||||
| {
|
||||
type: "card";
|
||||
|
|
@ -65,12 +83,31 @@ export type ServerMessage =
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
| {
|
||||
type: "workbench";
|
||||
id: string;
|
||||
chat_id: string;
|
||||
kind: "text" | "question";
|
||||
title: string;
|
||||
content: string;
|
||||
question: string;
|
||||
choices: string[];
|
||||
response_value: string;
|
||||
slot: string;
|
||||
template_key: string;
|
||||
template_state: Record<string, JsonValue>;
|
||||
context_summary: string;
|
||||
promotable: boolean;
|
||||
source_card_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
| { type: "error"; error: string }
|
||||
| { type: "pong" };
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }
|
||||
| { type: "command"; command: string }
|
||||
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata; chat_id?: string }
|
||||
| { type: "command"; command: string; chat_id?: string }
|
||||
| { type: "card-response"; card_id: string; value: string }
|
||||
| { type: "ping" };
|
||||
|
||||
|
|
@ -79,6 +116,7 @@ export interface LogLine {
|
|||
role: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
contextLabel?: string;
|
||||
}
|
||||
|
||||
export interface CardItem {
|
||||
|
|
@ -100,3 +138,22 @@ export interface CardItem {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchItem {
|
||||
id: string;
|
||||
chatId: string;
|
||||
kind: "text" | "question";
|
||||
title: string;
|
||||
content: string;
|
||||
question?: string;
|
||||
choices?: string[];
|
||||
responseValue?: string;
|
||||
slot?: string;
|
||||
templateKey?: string;
|
||||
templateState?: Record<string, JsonValue>;
|
||||
contextSummary?: string;
|
||||
promotable: boolean;
|
||||
sourceCardId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue