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",
"version": "0.1.0",
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",

View File

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

View File

@@ -176,6 +176,10 @@ export function WorkflowCanvas({
const [addOpen, setAddOpen] = useState(false);
const [addSourceId, setAddSourceId] = useState<string | 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();
// 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等
@@ -569,8 +573,8 @@ export function WorkflowCanvas({
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodeTypes={flowNodeTypes}
edgeTypes={flowEdgeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}

View File

@@ -9,6 +9,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import Daily from "@daily-co/daily-js";
import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js";
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
@@ -101,6 +102,21 @@ function publicVariableSnapshot(
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
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 {
try {
const message = JSON.parse(raw) as AppMessage;
@@ -262,6 +278,8 @@ export function useVoicePreview(
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
const connectionGenerationRef = useRef(0);
const startingRef = useRef(false);
const messageSeqRef = useRef(0);
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
@@ -321,9 +339,14 @@ export function useVoicePreview(
}, [refreshDevices]);
const releaseResources = useCallback(() => {
connectionGenerationRef.current += 1;
const transport = transportRef.current;
transportRef.current = null;
transport?.disconnect().catch(() => {});
if (transport) {
cleanupPromiseRef.current = cleanupPromiseRef.current
.then(() => transport.dispose())
.catch(() => {});
}
if (audioRef.current) audioRef.current.srcObject = null;
startingRef.current = false;
pendingAssistantTurnsRef.current.clear();
@@ -585,6 +608,8 @@ export function useVoicePreview(
}
startingRef.current = true;
const generation = connectionGenerationRef.current + 1;
connectionGenerationRef.current = generation;
setStatus("connecting");
setError(null);
setMicWarning(null);
@@ -594,10 +619,15 @@ export function useVoicePreview(
setCallEnded(false);
endedByServerRef.current = false;
await cleanupPromiseRef.current;
if (connectionGenerationRef.current !== generation) return;
const iceServers = await webrtcApi
.iceServers()
.then((response) => response.iceServers)
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
if (connectionGenerationRef.current !== generation) return;
const transport = new AppSmallWebRTCTransport({ iceServers });
transportRef.current = transport;
transport.onAppMessage = handleAppMessage;
@@ -648,9 +678,11 @@ export function useVoicePreview(
try {
await transport.initDevices();
if (connectionGenerationRef.current !== generation) return;
if (selectedDeviceIdRef.current) {
await transport.updateMic(selectedDeviceIdRef.current);
}
if (connectionGenerationRef.current !== generation) return;
const localAudio = transport.tracks().local.audio;
const localVideo = transport.tracks().local.video;
setLocalStream(localAudio ? new MediaStream([localAudio]) : null);
@@ -678,12 +710,17 @@ export function useVoicePreview(
},
},
});
if (connectionGenerationRef.current !== generation) return;
transport.sendAppMessage({ type: "client-ready" });
setStatus("connected");
} catch (connectionError) {
fail(errorMessage(connectionError, "无法连接语音服务。"));
if (connectionGenerationRef.current === generation) {
fail(errorMessage(connectionError, "无法连接语音服务。"));
}
} finally {
startingRef.current = false;
if (connectionGenerationRef.current === generation) {
startingRef.current = false;
}
}
}, [
assistantId,