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