stable
This commit is contained in:
parent
b7614eb3f8
commit
db4ce8b14f
22 changed files with 3557 additions and 823 deletions
|
|
@ -1,61 +1,275 @@
|
|||
import { useCallback, useEffect } from "preact/hooks";
|
||||
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 { ToastContainer } from "./components/Toast";
|
||||
import { useAudioMeter } from "./hooks/useAudioMeter";
|
||||
import { usePTT } from "./hooks/usePTT";
|
||||
import { useWebRTC } from "./hooks/useWebRTC";
|
||||
import type { CardItem, CardMessageMetadata, JsonValue } from "./types";
|
||||
|
||||
export function App() {
|
||||
const rtc = useWebRTC();
|
||||
const audioLevel = useAudioMeter(rtc.remoteStream);
|
||||
const SWIPE_THRESHOLD_PX = 64;
|
||||
const SWIPE_DIRECTION_RATIO = 1.15;
|
||||
|
||||
const { agentStateOverride, handlePointerDown, handlePointerUp } = usePTT({
|
||||
connected: rtc.connected,
|
||||
onSendPtt: (pressed) => rtc.sendJson({ type: "voice-ptt", pressed }),
|
||||
onBootstrap: rtc.connect,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
|
||||
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 liveContent = card.serverId
|
||||
? window.__nanobotGetCardLiveContent?.(card.serverId)
|
||||
: undefined;
|
||||
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
|
||||
document.addEventListener("pointerup", handlePointerUp, { passive: false });
|
||||
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("pointerup", handlePointerUp);
|
||||
document.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
}, [handlePointerDown, handlePointerUp]);
|
||||
function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) {
|
||||
return (
|
||||
<div id="agent-card-context" data-no-swipe="1">
|
||||
<div class="agent-card-context-label">Using card</div>
|
||||
<div class="agent-card-context-row">
|
||||
<div class="agent-card-context-title">{card.title}</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: "agent" | "feed",
|
||||
setView: (view: "agent" | "feed") => 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: "agent" | "feed") => 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 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 handleChoice = useCallback(
|
||||
(requestId: string, value: string) => {
|
||||
rtc.sendJson({ type: "ui-response", request_id: requestId, value });
|
||||
const handleToggleTextOnly = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
rtc.setTextOnly(enabled);
|
||||
if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
|
||||
},
|
||||
[rtc],
|
||||
);
|
||||
|
||||
return { handleReset, handleToggleTextOnly };
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const rtc = useWebRTC();
|
||||
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
|
||||
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
|
||||
|
||||
const [view, setView] = useState<"agent" | "feed">("agent");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
||||
const autoOpenedFeedRef = useRef(false);
|
||||
|
||||
const selectedCard = useMemo(
|
||||
() =>
|
||||
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
|
||||
[rtc.cards, selectedCardId],
|
||||
);
|
||||
const selectedCardMetadata = useCallback(
|
||||
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
|
||||
[selectedCard],
|
||||
);
|
||||
|
||||
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
|
||||
connected: rtc.connected && !rtc.textOnly,
|
||||
onSendPtt: (pressed) =>
|
||||
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
|
||||
onBootstrap: rtc.connect,
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
|
||||
document.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
document.addEventListener("pointerup", handlePointerUp, { passive: false });
|
||||
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
document.removeEventListener("pointerup", handlePointerUp);
|
||||
document.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpenedFeedRef.current || rtc.cards.length === 0) return;
|
||||
autoOpenedFeedRef.current = true;
|
||||
setView("feed");
|
||||
}, [rtc.cards.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCardId) return;
|
||||
if (rtc.cards.some((card) => card.serverId === selectedCardId)) return;
|
||||
setSelectedCardId(null);
|
||||
}, [rtc.cards, selectedCardId]);
|
||||
|
||||
const { handleToggleTextOnly } = useControlActions(rtc);
|
||||
const { handleAskCard } = useCardActions(setView, 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;
|
||||
setSelectedCardId(null);
|
||||
await rtc.connect();
|
||||
rtc.sendJson({ type: "command", command: "reset" });
|
||||
}, [rtc]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlBar onReset={handleReset} />
|
||||
<LogPanel lines={rtc.logLines} />
|
||||
<AgentIndicator
|
||||
state={effectiveAgentState}
|
||||
connected={rtc.connected}
|
||||
connecting={rtc.connecting}
|
||||
audioLevel={audioLevel}
|
||||
onPointerDown={() => {}}
|
||||
onPointerUp={() => {}}
|
||||
/>
|
||||
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
|
||||
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
|
||||
<section class="workspace-panel workspace-agent">
|
||||
{view === "agent" && (
|
||||
<ControlBar
|
||||
onReset={handleResetWithSelection}
|
||||
textOnly={rtc.textOnly}
|
||||
onToggleTextOnly={handleToggleTextOnly}
|
||||
/>
|
||||
)}
|
||||
{view === "agent" && selectedCard && (
|
||||
<AgentCardContext card={selectedCard} onClear={() => setSelectedCardId(null)} />
|
||||
)}
|
||||
{view === "agent" && (
|
||||
<LogPanel
|
||||
lines={rtc.logLines}
|
||||
disabled={!rtc.connected}
|
||||
onSendMessage={handleSendMessage}
|
||||
onExpandChange={setComposing}
|
||||
/>
|
||||
)}
|
||||
<AgentIndicator
|
||||
state={effectiveAgentState}
|
||||
connected={rtc.connected}
|
||||
connecting={rtc.connecting}
|
||||
audioLevel={audioLevel}
|
||||
viewActive
|
||||
onPointerDown={() => {}}
|
||||
onPointerUp={() => {}}
|
||||
/>
|
||||
</section>
|
||||
<section class="workspace-panel workspace-feed">
|
||||
<CardFeed
|
||||
cards={rtc.cards}
|
||||
viewActive
|
||||
onDismiss={rtc.dismissCard}
|
||||
onChoice={handleCardChoice}
|
||||
onAskCard={handleAskCard}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
|
||||
<ToastContainer toasts={rtc.toasts} onDismiss={rtc.dismissToast} onChoice={handleChoice} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue