fix(frontend): clean up voice preview listeners

This commit is contained in:
Xin Wang
2026-08-03 12:30:49 +08:00
parent 841d7b8fac
commit b0991f239e
4 changed files with 48 additions and 5 deletions

View File

@@ -8,6 +8,7 @@
"name": "ai-video-admin-frontend", "name": "ai-video-admin-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@pipecat-ai/client-js": "^1.12.0", "@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5", "@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0", "@xyflow/react": "^12.11.0",

View File

@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@pipecat-ai/client-js": "^1.12.0", "@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5", "@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0", "@xyflow/react": "^12.11.0",

View File

@@ -176,6 +176,10 @@ export function WorkflowCanvas({
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [addSourceId, setAddSourceId] = useState<string | null>(null); const [addSourceId, setAddSourceId] = useState<string | null>(null);
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null); const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
// Fast Refresh 会替换模块级对象,而保留 React Flow 实例。用 state 固定本次
// 挂载使用的映射,避免热更新后被误判为每次渲染都在创建新对象。
const [flowNodeTypes] = useState(nodeTypes);
const [flowEdgeTypes] = useState(edgeTypes);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
// 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等 // 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等
@@ -569,8 +573,8 @@ export function WorkflowCanvas({
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={flowNodeTypes}
edgeTypes={edgeTypes} edgeTypes={flowEdgeTypes}
onNodesChange={handleNodesChange} onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}

View File

@@ -9,6 +9,7 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Daily from "@daily-co/daily-js";
import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js"; import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js";
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport"; import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
@@ -101,6 +102,21 @@ function publicVariableSnapshot(
class AppSmallWebRTCTransport extends SmallWebRTCTransport { class AppSmallWebRTCTransport extends SmallWebRTCTransport {
onAppMessage?: (message: AppMessage) => void; onAppMessage?: (message: AppMessage) => void;
/**
* SmallWebRTCTransport 的媒体管理器会复用 Daily 的全局 call object
* 但 disconnect() 不会移除构造时注册的设备和 track 监听器。销毁 call
* object 后,下一次连接会得到干净的实例,避免监听器跨会话累积。
*/
async dispose(): Promise<void> {
this.onAppMessage = undefined;
try {
await this.disconnect();
} finally {
const call = Daily.getCallInstance();
if (call && !call.isDestroyed()) await call.destroy();
}
}
override handleMessage(raw: string): void { override handleMessage(raw: string): void {
try { try {
const message = JSON.parse(raw) as AppMessage; const message = JSON.parse(raw) as AppMessage;
@@ -262,6 +278,8 @@ export function useVoicePreview(
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState(""); const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
const transportRef = useRef<AppSmallWebRTCTransport | null>(null); const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
const connectionGenerationRef = useRef(0);
const startingRef = useRef(false); const startingRef = useRef(false);
const messageSeqRef = useRef(0); const messageSeqRef = useRef(0);
const networkStatsRef = useRef<NetworkStatsSample | null>(null); const networkStatsRef = useRef<NetworkStatsSample | null>(null);
@@ -321,9 +339,14 @@ export function useVoicePreview(
}, [refreshDevices]); }, [refreshDevices]);
const releaseResources = useCallback(() => { const releaseResources = useCallback(() => {
connectionGenerationRef.current += 1;
const transport = transportRef.current; const transport = transportRef.current;
transportRef.current = null; transportRef.current = null;
transport?.disconnect().catch(() => {}); if (transport) {
cleanupPromiseRef.current = cleanupPromiseRef.current
.then(() => transport.dispose())
.catch(() => {});
}
if (audioRef.current) audioRef.current.srcObject = null; if (audioRef.current) audioRef.current.srcObject = null;
startingRef.current = false; startingRef.current = false;
pendingAssistantTurnsRef.current.clear(); pendingAssistantTurnsRef.current.clear();
@@ -585,6 +608,8 @@ export function useVoicePreview(
} }
startingRef.current = true; startingRef.current = true;
const generation = connectionGenerationRef.current + 1;
connectionGenerationRef.current = generation;
setStatus("connecting"); setStatus("connecting");
setError(null); setError(null);
setMicWarning(null); setMicWarning(null);
@@ -594,10 +619,15 @@ export function useVoicePreview(
setCallEnded(false); setCallEnded(false);
endedByServerRef.current = false; endedByServerRef.current = false;
await cleanupPromiseRef.current;
if (connectionGenerationRef.current !== generation) return;
const iceServers = await webrtcApi const iceServers = await webrtcApi
.iceServers() .iceServers()
.then((response) => response.iceServers) .then((response) => response.iceServers)
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]); .catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
if (connectionGenerationRef.current !== generation) return;
const transport = new AppSmallWebRTCTransport({ iceServers }); const transport = new AppSmallWebRTCTransport({ iceServers });
transportRef.current = transport; transportRef.current = transport;
transport.onAppMessage = handleAppMessage; transport.onAppMessage = handleAppMessage;
@@ -648,9 +678,11 @@ export function useVoicePreview(
try { try {
await transport.initDevices(); await transport.initDevices();
if (connectionGenerationRef.current !== generation) return;
if (selectedDeviceIdRef.current) { if (selectedDeviceIdRef.current) {
await transport.updateMic(selectedDeviceIdRef.current); await transport.updateMic(selectedDeviceIdRef.current);
} }
if (connectionGenerationRef.current !== generation) return;
const localAudio = transport.tracks().local.audio; const localAudio = transport.tracks().local.audio;
const localVideo = transport.tracks().local.video; const localVideo = transport.tracks().local.video;
setLocalStream(localAudio ? new MediaStream([localAudio]) : null); setLocalStream(localAudio ? new MediaStream([localAudio]) : null);
@@ -678,13 +710,18 @@ export function useVoicePreview(
}, },
}, },
}); });
if (connectionGenerationRef.current !== generation) return;
transport.sendAppMessage({ type: "client-ready" }); transport.sendAppMessage({ type: "client-ready" });
setStatus("connected"); setStatus("connected");
} catch (connectionError) { } catch (connectionError) {
if (connectionGenerationRef.current === generation) {
fail(errorMessage(connectionError, "无法连接语音服务。")); fail(errorMessage(connectionError, "无法连接语音服务。"));
}
} finally { } finally {
if (connectionGenerationRef.current === generation) {
startingRef.current = false; startingRef.current = false;
} }
}
}, [ }, [
assistantId, assistantId,
applyOutputDevice, applyOutputDevice,