diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f14089a..1d61ee3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 919b5fe..020a51d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/workflow/WorkflowCanvas.tsx b/frontend/src/components/workflow/WorkflowCanvas.tsx index a8d6d56..0da50b7 100644 --- a/frontend/src/components/workflow/WorkflowCanvas.tsx +++ b/frontend/src/components/workflow/WorkflowCanvas.tsx @@ -176,6 +176,10 @@ export function WorkflowCanvas({ const [addOpen, setAddOpen] = useState(false); const [addSourceId, setAddSourceId] = useState(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({ void; + /** + * SmallWebRTCTransport 的媒体管理器会复用 Daily 的全局 call object, + * 但 disconnect() 不会移除构造时注册的设备和 track 监听器。销毁 call + * object 后,下一次连接会得到干净的实例,避免监听器跨会话累积。 + */ + async dispose(): Promise { + 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(null); + const cleanupPromiseRef = useRef>(Promise.resolve()); + const connectionGenerationRef = useRef(0); const startingRef = useRef(false); const messageSeqRef = useRef(0); const networkStatsRef = useRef(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,