2026-06-10 10:17:46 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-13 06:50:23 +08:00
|
|
|
|
* 语音预览。
|
2026-06-10 10:17:46 +08:00
|
|
|
|
*
|
2026-07-13 06:50:23 +08:00
|
|
|
|
* WebRTC 生命周期交给 Pipecat 官方 SmallWebRTCTransport:它负责固定的
|
|
|
|
|
|
* audio/video transceiver、trackStatus、keepalive、重协商和 ICE 恢复。
|
|
|
|
|
|
* 本文件只把平台已有的业务消息映射到 React 状态。
|
2026-06-10 10:17:46 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-08-03 12:30:49 +08:00
|
|
|
|
import Daily from "@daily-co/daily-js";
|
2026-07-13 06:50:23 +08:00
|
|
|
|
import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js";
|
|
|
|
|
|
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-06 17:45:53 +08:00
|
|
|
|
import { API_BASE, webrtcApi } from "@/lib/api";
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
|
|
|
|
|
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
|
2026-07-10 15:25:26 +08:00
|
|
|
|
export type NetworkQuality = "unknown" | "good" | "fair" | "poor";
|
|
|
|
|
|
|
2026-07-13 08:01:39 +08:00
|
|
|
|
const NETWORK_SAMPLE_INTERVAL_MS = 2_000;
|
2026-07-31 00:01:01 +08:00
|
|
|
|
const SESSION_UPDATE_TIMEOUT_MS = 10_000;
|
2026-07-13 08:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
type NetworkStatsSample = {
|
|
|
|
|
|
packetsSent: number;
|
|
|
|
|
|
packetsLost: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type NetworkMetrics = NetworkStatsSample & {
|
|
|
|
|
|
roundTripTime: number | null;
|
|
|
|
|
|
availableOutgoingBitrate: number | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-07 19:41:45 +08:00
|
|
|
|
type ConnectOptions = {
|
|
|
|
|
|
visionEnabled?: boolean;
|
|
|
|
|
|
videoStream?: MediaStream | null;
|
2026-07-12 23:42:56 +08:00
|
|
|
|
dynamicVariables?: Record<string, string | number | boolean>;
|
2026-07-07 19:41:45 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-10 15:11:34 +08:00
|
|
|
|
export type ChatMessage = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
timestamp: string;
|
2026-06-14 22:18:21 +08:00
|
|
|
|
sequence: number;
|
|
|
|
|
|
turnId?: string;
|
|
|
|
|
|
streaming?: boolean;
|
2026-06-10 15:11:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
type AppMessage = Record<string, unknown> & { type?: string };
|
2026-07-31 00:01:01 +08:00
|
|
|
|
export type DynamicVariableValue = string | number | boolean;
|
2026-07-30 19:06:03 +08:00
|
|
|
|
export type UserInputPart =
|
|
|
|
|
|
| { type: "input_text"; text: string }
|
|
|
|
|
|
| {
|
|
|
|
|
|
type: "input_image";
|
|
|
|
|
|
source: { type: "camera_frame"; frame: "current" };
|
|
|
|
|
|
};
|
|
|
|
|
|
export type UserInputResult = {
|
|
|
|
|
|
inputId: string;
|
|
|
|
|
|
status: "accepted";
|
|
|
|
|
|
};
|
2026-07-31 00:01:01 +08:00
|
|
|
|
export type SessionUpdateRequest = {
|
|
|
|
|
|
dynamicVariables: Record<string, DynamicVariableValue>;
|
|
|
|
|
|
};
|
|
|
|
|
|
export type SessionUpdateResult = {
|
|
|
|
|
|
updateId: string;
|
|
|
|
|
|
status: "accepted";
|
|
|
|
|
|
changed: string[];
|
|
|
|
|
|
dynamicVariables: Record<string, DynamicVariableValue>;
|
|
|
|
|
|
};
|
2026-07-30 19:06:03 +08:00
|
|
|
|
export type ClientToolHandler = (
|
|
|
|
|
|
argumentsValue: Record<string, unknown>,
|
|
|
|
|
|
) => unknown | Promise<unknown>;
|
2026-08-03 14:00:33 +08:00
|
|
|
|
export type ClientToolDefinition = {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
parameters: readonly ClientToolParameter[];
|
|
|
|
|
|
};
|
|
|
|
|
|
export type ClientToolParameter = {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
type: string;
|
|
|
|
|
|
required: boolean;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type RegisteredClientTool = {
|
|
|
|
|
|
definition: ClientToolDefinition;
|
|
|
|
|
|
handler: ClientToolHandler;
|
|
|
|
|
|
};
|
2026-07-30 19:06:03 +08:00
|
|
|
|
|
|
|
|
|
|
type PendingUserInput = {
|
|
|
|
|
|
resolve: (result: UserInputResult) => void;
|
|
|
|
|
|
reject: (error: Error) => void;
|
|
|
|
|
|
timeout: number;
|
|
|
|
|
|
};
|
2026-07-14 11:08:11 +08:00
|
|
|
|
|
2026-07-31 00:01:01 +08:00
|
|
|
|
type PendingSessionUpdate = {
|
|
|
|
|
|
resolve: (result: SessionUpdateResult) => void;
|
|
|
|
|
|
reject: (error: Error) => void;
|
|
|
|
|
|
timeout: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-14 11:08:11 +08:00
|
|
|
|
function publicVariableSnapshot(
|
|
|
|
|
|
value: unknown,
|
|
|
|
|
|
): Record<string, DynamicVariableValue> {
|
|
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
|
Object.entries(value).filter(([name, item]) =>
|
|
|
|
|
|
!name.startsWith("system__") &&
|
|
|
|
|
|
!name.startsWith("secret__") &&
|
|
|
|
|
|
(typeof item === "string" ||
|
|
|
|
|
|
typeof item === "number" ||
|
|
|
|
|
|
typeof item === "boolean"),
|
|
|
|
|
|
) as [string, DynamicVariableValue][],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
|
|
|
|
|
|
onAppMessage?: (message: AppMessage) => void;
|
|
|
|
|
|
|
2026-08-03 12:30:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
override handleMessage(raw: string): void {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const message = JSON.parse(raw) as AppMessage;
|
|
|
|
|
|
if (message.type === "signalling" || message.label === "rtvi-ai") {
|
|
|
|
|
|
super.handleMessage(raw);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.onAppMessage?.(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
super.handleMessage(raw);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sendAppMessage(message: AppMessage): void {
|
|
|
|
|
|
super.sendMessage(message as unknown as RTVIMessage);
|
|
|
|
|
|
}
|
2026-07-13 08:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
async connectionStats(): Promise<RTCStatsReport | null> {
|
|
|
|
|
|
const peerConnection = (
|
|
|
|
|
|
this as unknown as { pc: RTCPeerConnection | null }
|
|
|
|
|
|
).pc;
|
|
|
|
|
|
return peerConnection ? peerConnection.getStats() : null;
|
|
|
|
|
|
}
|
2026-06-10 10:17:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function errorMessage(error: unknown, fallback: string): string {
|
|
|
|
|
|
if (error instanceof Error && error.message) return error.message;
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 22:12:56 +08:00
|
|
|
|
function messageOrder(message: ChatMessage): number {
|
|
|
|
|
|
const timestamp = Date.parse(message.timestamp);
|
|
|
|
|
|
return Number.isNaN(timestamp) ? Number.MAX_SAFE_INTEGER : timestamp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 22:18:21 +08:00
|
|
|
|
function sortMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
|
|
|
|
return messages.sort(
|
|
|
|
|
|
(a, b) => messageOrder(a) - messageOrder(b) || a.sequence - b.sequence,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
function encodeHeaderJson(value: unknown): string {
|
|
|
|
|
|
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
|
|
|
|
let binary = "";
|
|
|
|
|
|
bytes.forEach((byte) => {
|
|
|
|
|
|
binary += String.fromCharCode(byte);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
});
|
2026-07-13 06:50:23 +08:00
|
|
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
2026-07-10 15:25:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 19:06:03 +08:00
|
|
|
|
function newInputId(): string {
|
|
|
|
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
|
|
|
|
return `input_${crypto.randomUUID()}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `input_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 00:01:01 +08:00
|
|
|
|
function newUpdateId(): string {
|
|
|
|
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
|
|
|
|
return `update_${crypto.randomUUID()}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `update_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:01:39 +08:00
|
|
|
|
function readNetworkMetrics(report: RTCStatsReport): NetworkMetrics | null {
|
|
|
|
|
|
const outbound: Partial<Record<"audio" | "video", number>> = {};
|
|
|
|
|
|
const lost: Partial<Record<"audio" | "video", number>> = {};
|
|
|
|
|
|
let roundTripTime: number | null = null;
|
|
|
|
|
|
let availableOutgoingBitrate: number | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
report.forEach((stat) => {
|
|
|
|
|
|
const value = stat as RTCStats & Record<string, unknown>;
|
|
|
|
|
|
const kind = value.kind ?? value.mediaType;
|
|
|
|
|
|
if (
|
|
|
|
|
|
value.type === "outbound-rtp" &&
|
|
|
|
|
|
(kind === "audio" || kind === "video") &&
|
|
|
|
|
|
typeof value.packetsSent === "number"
|
|
|
|
|
|
) {
|
|
|
|
|
|
outbound[kind] = value.packetsSent;
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
value.type === "remote-inbound-rtp" &&
|
|
|
|
|
|
(kind === "audio" || kind === "video")
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (typeof value.packetsLost === "number") lost[kind] = value.packetsLost;
|
|
|
|
|
|
if (typeof value.roundTripTime === "number") {
|
|
|
|
|
|
roundTripTime = value.roundTripTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
value.type === "candidate-pair" &&
|
|
|
|
|
|
value.state === "succeeded" &&
|
|
|
|
|
|
(value.nominated === true || value.selected === true)
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (typeof value.currentRoundTripTime === "number") {
|
|
|
|
|
|
roundTripTime = value.currentRoundTripTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof value.availableOutgoingBitrate === "number") {
|
|
|
|
|
|
availableOutgoingBitrate = value.availableOutgoingBitrate;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const kind = outbound.video !== undefined ? "video" : "audio";
|
|
|
|
|
|
if (outbound[kind] === undefined && roundTripTime === null) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
packetsSent: outbound[kind] ?? 0,
|
|
|
|
|
|
packetsLost: lost[kind] ?? 0,
|
|
|
|
|
|
roundTripTime,
|
|
|
|
|
|
availableOutgoingBitrate,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function classifyNetworkQuality(
|
|
|
|
|
|
metrics: NetworkMetrics,
|
|
|
|
|
|
previous: NetworkStatsSample | null,
|
|
|
|
|
|
): NetworkQuality {
|
|
|
|
|
|
const sentDelta = previous
|
|
|
|
|
|
? Math.max(0, metrics.packetsSent - previous.packetsSent)
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
const lostDelta = previous
|
|
|
|
|
|
? Math.max(0, metrics.packetsLost - previous.packetsLost)
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
const lossRate = sentDelta > 0 ? lostDelta / sentDelta : null;
|
|
|
|
|
|
const { roundTripTime: rtt, availableOutgoingBitrate: bandwidth } = metrics;
|
|
|
|
|
|
|
|
|
|
|
|
if (lossRate === null && rtt === null && bandwidth === null) return "unknown";
|
|
|
|
|
|
if (
|
|
|
|
|
|
(lossRate !== null && lossRate >= 0.08) ||
|
|
|
|
|
|
(rtt !== null && rtt >= 0.45) ||
|
|
|
|
|
|
(bandwidth !== null && bandwidth < 350_000)
|
|
|
|
|
|
) return "poor";
|
|
|
|
|
|
if (
|
|
|
|
|
|
(lossRate !== null && lossRate >= 0.03) ||
|
|
|
|
|
|
(rtt !== null && rtt >= 0.25) ||
|
|
|
|
|
|
(bandwidth !== null && bandwidth < 550_000)
|
|
|
|
|
|
) return "fair";
|
|
|
|
|
|
return "good";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:32:10 +08:00
|
|
|
|
export function useVoicePreview(
|
|
|
|
|
|
assistantId: string | null,
|
|
|
|
|
|
onNodeActive?: (nodeId: string | null) => void,
|
|
|
|
|
|
) {
|
2026-06-10 10:17:46 +08:00
|
|
|
|
const [status, setStatus] = useState<VoicePreviewStatus>("idle");
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-06-10 12:36:18 +08:00
|
|
|
|
const [micWarning, setMicWarning] = useState<string | null>(null);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
2026-07-13 07:05:06 +08:00
|
|
|
|
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
|
2026-06-11 10:51:08 +08:00
|
|
|
|
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
2026-06-10 15:11:34 +08:00
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
2026-07-14 11:08:11 +08:00
|
|
|
|
const [sessionVariables, setSessionVariables] = useState<
|
|
|
|
|
|
Record<string, DynamicVariableValue>
|
|
|
|
|
|
>({});
|
2026-07-10 22:48:40 +08:00
|
|
|
|
const [callEnded, setCallEnded] = useState(false);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
const [networkQuality, setNetworkQuality] =
|
|
|
|
|
|
useState<NetworkQuality>("unknown");
|
2026-06-10 15:26:33 +08:00
|
|
|
|
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
|
2026-07-10 11:09:44 +08:00
|
|
|
|
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
|
|
|
|
|
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
|
2026-08-03 14:00:33 +08:00
|
|
|
|
const [clientTools, setClientTools] = useState<ClientToolDefinition[]>([]);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
|
|
|
|
|
|
const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
|
2026-08-03 12:30:49 +08:00
|
|
|
|
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
|
|
|
|
|
|
const connectionGenerationRef = useRef(0);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const startingRef = useRef(false);
|
|
|
|
|
|
const messageSeqRef = useRef(0);
|
2026-07-13 08:01:39 +08:00
|
|
|
|
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
|
|
|
|
|
|
const pendingAssistantTurnsRef = useRef(
|
|
|
|
|
|
new Map<string, { timestamp: string }>(),
|
|
|
|
|
|
);
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const pendingUserInputsRef = useRef(new Map<string, PendingUserInput>());
|
2026-07-31 00:01:01 +08:00
|
|
|
|
const pendingSessionUpdatesRef = useRef(
|
|
|
|
|
|
new Map<string, PendingSessionUpdate>(),
|
|
|
|
|
|
);
|
2026-08-03 14:00:33 +08:00
|
|
|
|
const clientToolHandlersRef = useRef(
|
|
|
|
|
|
new Map<string, RegisteredClientTool>(),
|
|
|
|
|
|
);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const endedByServerRef = useRef(false);
|
2026-06-10 15:26:33 +08:00
|
|
|
|
const selectedDeviceIdRef = useRef("");
|
2026-07-10 11:09:44 +08:00
|
|
|
|
const selectedOutputDeviceIdRef = useRef("");
|
2026-06-15 15:32:10 +08:00
|
|
|
|
const onNodeActiveRef = useRef(onNodeActive);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
|
|
|
2026-06-15 15:32:10 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onNodeActiveRef.current = onNodeActive;
|
|
|
|
|
|
}, [onNodeActive]);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-10 11:09:44 +08:00
|
|
|
|
const supportsOutputSelection =
|
|
|
|
|
|
typeof HTMLAudioElement !== "undefined" &&
|
|
|
|
|
|
"setSinkId" in HTMLAudioElement.prototype;
|
|
|
|
|
|
|
|
|
|
|
|
const applyOutputDevice = useCallback(async (deviceId: string) => {
|
|
|
|
|
|
const audio = audioRef.current;
|
|
|
|
|
|
if (!audio || !supportsOutputSelection) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await audio.setSinkId(deviceId);
|
|
|
|
|
|
} catch {
|
2026-07-13 06:50:23 +08:00
|
|
|
|
// 设备可能已拔出,交给浏览器回退默认输出。
|
2026-07-10 11:09:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [supportsOutputSelection]);
|
|
|
|
|
|
|
2026-06-10 15:26:33 +08:00
|
|
|
|
const refreshDevices = useCallback(async () => {
|
|
|
|
|
|
if (!navigator.mediaDevices?.enumerateDevices) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
2026-07-13 06:50:23 +08:00
|
|
|
|
setAudioInputs(
|
|
|
|
|
|
devices.filter((item) => item.kind === "audioinput" && item.deviceId),
|
|
|
|
|
|
);
|
|
|
|
|
|
setAudioOutputs(
|
|
|
|
|
|
devices.filter((item) => item.kind === "audiooutput" && item.deviceId),
|
2026-07-10 11:09:44 +08:00
|
|
|
|
);
|
2026-06-10 15:26:33 +08:00
|
|
|
|
} catch {
|
2026-07-13 06:50:23 +08:00
|
|
|
|
// 设备枚举失败不影响仅接收模式。
|
2026-06-10 15:26:33 +08:00
|
|
|
|
}
|
2026-07-13 06:50:23 +08:00
|
|
|
|
}, []);
|
2026-06-10 15:26:33 +08:00
|
|
|
|
|
2026-07-10 11:09:44 +08:00
|
|
|
|
useEffect(() => {
|
2026-06-10 15:26:33 +08:00
|
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
|
|
|
|
void refreshDevices();
|
2026-07-13 06:50:23 +08:00
|
|
|
|
navigator.mediaDevices?.addEventListener("devicechange", refreshDevices);
|
|
|
|
|
|
return () =>
|
|
|
|
|
|
navigator.mediaDevices?.removeEventListener("devicechange", refreshDevices);
|
2026-06-10 15:26:33 +08:00
|
|
|
|
}, [refreshDevices]);
|
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
|
const releaseResources = useCallback(() => {
|
2026-08-03 12:30:49 +08:00
|
|
|
|
connectionGenerationRef.current += 1;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
transportRef.current = null;
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (transport) {
|
|
|
|
|
|
cleanupPromiseRef.current = cleanupPromiseRef.current
|
|
|
|
|
|
.then(() => transport.dispose())
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}
|
2026-06-10 10:17:46 +08:00
|
|
|
|
if (audioRef.current) audioRef.current.srcObject = null;
|
|
|
|
|
|
startingRef.current = false;
|
2026-07-13 08:01:39 +08:00
|
|
|
|
pendingAssistantTurnsRef.current.clear();
|
2026-07-30 19:06:03 +08:00
|
|
|
|
pendingUserInputsRef.current.forEach(({ reject, timeout }) => {
|
|
|
|
|
|
window.clearTimeout(timeout);
|
|
|
|
|
|
reject(new Error("连接已关闭,用户输入未完成"));
|
|
|
|
|
|
});
|
|
|
|
|
|
pendingUserInputsRef.current.clear();
|
2026-07-31 00:01:01 +08:00
|
|
|
|
pendingSessionUpdatesRef.current.forEach(({ reject, timeout }) => {
|
|
|
|
|
|
window.clearTimeout(timeout);
|
|
|
|
|
|
reject(new Error("连接已关闭,会话状态更新未完成"));
|
|
|
|
|
|
});
|
|
|
|
|
|
pendingSessionUpdatesRef.current.clear();
|
2026-07-13 08:01:39 +08:00
|
|
|
|
networkStatsRef.current = null;
|
2026-06-15 15:32:10 +08:00
|
|
|
|
onNodeActiveRef.current?.(null);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const disconnect = useCallback(() => {
|
|
|
|
|
|
releaseResources();
|
|
|
|
|
|
setLocalStream(null);
|
2026-07-13 07:05:06 +08:00
|
|
|
|
setVideoStream(null);
|
2026-06-11 10:51:08 +08:00
|
|
|
|
setRemoteStream(null);
|
2026-07-07 10:07:36 +08:00
|
|
|
|
setMessages([]);
|
|
|
|
|
|
messageSeqRef.current = 0;
|
2026-07-13 08:01:39 +08:00
|
|
|
|
pendingAssistantTurnsRef.current.clear();
|
2026-06-10 10:17:46 +08:00
|
|
|
|
setError(null);
|
2026-06-10 12:36:18 +08:00
|
|
|
|
setMicWarning(null);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
setNetworkQuality("unknown");
|
2026-06-10 10:17:46 +08:00
|
|
|
|
setStatus("idle");
|
|
|
|
|
|
}, [releaseResources]);
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const fail = useCallback((message: string) => {
|
|
|
|
|
|
releaseResources();
|
|
|
|
|
|
setLocalStream(null);
|
2026-07-13 07:05:06 +08:00
|
|
|
|
setVideoStream(null);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
setRemoteStream(null);
|
|
|
|
|
|
setError(message);
|
|
|
|
|
|
setNetworkQuality("unknown");
|
|
|
|
|
|
setStatus("failed");
|
|
|
|
|
|
}, [releaseResources]);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const dispatchClientTool = useCallback(async (msg: AppMessage) => {
|
|
|
|
|
|
const toolCallId =
|
|
|
|
|
|
typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
|
|
|
|
|
|
const functionName =
|
|
|
|
|
|
typeof msg.function_name === "string" ? msg.function_name : "";
|
|
|
|
|
|
const waitForResponse = msg.wait_for_response !== false;
|
|
|
|
|
|
if (!toolCallId || !functionName) return;
|
|
|
|
|
|
|
2026-08-03 14:00:33 +08:00
|
|
|
|
const registeredTool = clientToolHandlersRef.current.get(functionName);
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!transport || transport.state !== "connected") return;
|
2026-08-03 14:00:33 +08:00
|
|
|
|
if (!registeredTool) {
|
2026-07-30 19:06:03 +08:00
|
|
|
|
if (waitForResponse) {
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "client-tool-result",
|
|
|
|
|
|
tool_call_id: toolCallId,
|
|
|
|
|
|
status: "error",
|
|
|
|
|
|
message: `客户端未注册工具: ${functionName}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const argumentsValue =
|
|
|
|
|
|
msg.arguments &&
|
|
|
|
|
|
typeof msg.arguments === "object" &&
|
|
|
|
|
|
!Array.isArray(msg.arguments)
|
|
|
|
|
|
? (msg.arguments as Record<string, unknown>)
|
|
|
|
|
|
: {};
|
2026-08-03 14:00:33 +08:00
|
|
|
|
const data = await registeredTool.handler(argumentsValue);
|
2026-07-30 19:06:03 +08:00
|
|
|
|
if (transportRef.current !== transport) return;
|
|
|
|
|
|
if (!waitForResponse) return;
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "client-tool-result",
|
|
|
|
|
|
tool_call_id: toolCallId,
|
|
|
|
|
|
status: "ok",
|
|
|
|
|
|
data,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (toolError) {
|
|
|
|
|
|
if (transportRef.current !== transport) return;
|
|
|
|
|
|
if (!waitForResponse) return;
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "client-tool-result",
|
|
|
|
|
|
tool_call_id: toolCallId,
|
|
|
|
|
|
status: "error",
|
|
|
|
|
|
message: errorMessage(toolError, "客户端工具执行失败"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const handleAppMessage = useCallback((msg: AppMessage) => {
|
2026-07-30 19:06:03 +08:00
|
|
|
|
if (msg.type === "client-tool-call") {
|
|
|
|
|
|
void dispatchClientTool(msg);
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
msg.type === "user-input-result" &&
|
|
|
|
|
|
typeof msg.input_id === "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const pending = pendingUserInputsRef.current.get(msg.input_id);
|
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
|
pendingUserInputsRef.current.delete(msg.input_id);
|
|
|
|
|
|
window.clearTimeout(pending.timeout);
|
|
|
|
|
|
if (msg.status === "accepted") {
|
|
|
|
|
|
pending.resolve({ inputId: msg.input_id, status: "accepted" });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pending.reject(
|
|
|
|
|
|
new Error(
|
|
|
|
|
|
typeof msg.message === "string" ? msg.message : "用户输入处理失败",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-31 00:01:01 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
msg.type === "session-update-result" &&
|
|
|
|
|
|
typeof msg.update_id === "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const pending = pendingSessionUpdatesRef.current.get(msg.update_id);
|
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
|
pendingSessionUpdatesRef.current.delete(msg.update_id);
|
|
|
|
|
|
window.clearTimeout(pending.timeout);
|
|
|
|
|
|
if (msg.status === "accepted") {
|
|
|
|
|
|
const dynamicVariables = publicVariableSnapshot(msg.dynamic_variables);
|
|
|
|
|
|
const changed = Array.isArray(msg.changed)
|
|
|
|
|
|
? msg.changed.filter((name): name is string => typeof name === "string")
|
|
|
|
|
|
: [];
|
|
|
|
|
|
setSessionVariables(dynamicVariables);
|
|
|
|
|
|
pending.resolve({
|
|
|
|
|
|
updateId: msg.update_id,
|
|
|
|
|
|
status: "accepted",
|
|
|
|
|
|
changed,
|
|
|
|
|
|
dynamicVariables,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pending.reject(
|
|
|
|
|
|
new Error(
|
|
|
|
|
|
typeof msg.message === "string" ? msg.message : "会话状态更新失败",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-30 19:06:03 +08:00
|
|
|
|
} else if (
|
2026-07-13 06:50:23 +08:00
|
|
|
|
msg.type === "assistant-text-start" &&
|
|
|
|
|
|
typeof msg.turn_id === "string"
|
|
|
|
|
|
) {
|
2026-07-13 08:01:39 +08:00
|
|
|
|
pendingAssistantTurnsRef.current.set(msg.turn_id, {
|
|
|
|
|
|
timestamp:
|
|
|
|
|
|
typeof msg.timestamp === "string"
|
|
|
|
|
|
? msg.timestamp
|
|
|
|
|
|
: new Date().toISOString(),
|
|
|
|
|
|
});
|
2026-07-13 06:50:23 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
msg.type === "assistant-text-delta" &&
|
|
|
|
|
|
typeof msg.turn_id === "string" &&
|
2026-07-13 08:01:39 +08:00
|
|
|
|
typeof msg.delta === "string" &&
|
|
|
|
|
|
msg.delta.length > 0
|
2026-07-13 06:50:23 +08:00
|
|
|
|
) {
|
2026-07-13 08:01:39 +08:00
|
|
|
|
const pending = pendingAssistantTurnsRef.current.get(msg.turn_id);
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
pendingAssistantTurnsRef.current.delete(msg.turn_id);
|
|
|
|
|
|
messageSeqRef.current += 1;
|
|
|
|
|
|
const sequence = messageSeqRef.current;
|
|
|
|
|
|
setMessages((previous) =>
|
|
|
|
|
|
sortMessages([
|
|
|
|
|
|
...previous,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: `assistant-${msg.turn_id as string}`,
|
|
|
|
|
|
role: "assistant",
|
|
|
|
|
|
content: msg.delta as string,
|
|
|
|
|
|
timestamp: pending.timestamp,
|
|
|
|
|
|
sequence,
|
|
|
|
|
|
turnId: msg.turn_id as string,
|
|
|
|
|
|
streaming: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setMessages((previous) =>
|
|
|
|
|
|
previous.map((message) =>
|
|
|
|
|
|
message.turnId === msg.turn_id
|
|
|
|
|
|
? { ...message, content: message.content + msg.delta }
|
|
|
|
|
|
: message,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-13 06:50:23 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
msg.type === "assistant-text-end" &&
|
|
|
|
|
|
typeof msg.turn_id === "string"
|
|
|
|
|
|
) {
|
2026-07-13 08:01:39 +08:00
|
|
|
|
const pending = pendingAssistantTurnsRef.current.get(msg.turn_id);
|
|
|
|
|
|
pendingAssistantTurnsRef.current.delete(msg.turn_id);
|
|
|
|
|
|
const finalContent =
|
|
|
|
|
|
typeof msg.content === "string" ? msg.content.trim() : "";
|
|
|
|
|
|
setMessages((previous) => {
|
|
|
|
|
|
const existing = previous.some((message) => message.turnId === msg.turn_id);
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
return previous.map((message) =>
|
|
|
|
|
|
message.turnId === msg.turn_id
|
|
|
|
|
|
? {
|
|
|
|
|
|
...message,
|
|
|
|
|
|
content: finalContent || message.content,
|
|
|
|
|
|
streaming: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
: message,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!finalContent) return previous;
|
|
|
|
|
|
messageSeqRef.current += 1;
|
|
|
|
|
|
return sortMessages([
|
|
|
|
|
|
...previous,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: `assistant-${msg.turn_id as string}`,
|
|
|
|
|
|
role: "assistant",
|
|
|
|
|
|
content: finalContent,
|
|
|
|
|
|
timestamp: pending?.timestamp ?? new Date().toISOString(),
|
|
|
|
|
|
sequence: messageSeqRef.current,
|
|
|
|
|
|
turnId: msg.turn_id as string,
|
|
|
|
|
|
streaming: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
2026-07-13 06:50:23 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
msg.type === "transcript" &&
|
|
|
|
|
|
(msg.role === "user" || msg.role === "assistant") &&
|
|
|
|
|
|
typeof msg.content === "string" &&
|
|
|
|
|
|
msg.content.trim()
|
|
|
|
|
|
) {
|
|
|
|
|
|
messageSeqRef.current += 1;
|
|
|
|
|
|
setMessages((previous) =>
|
|
|
|
|
|
sortMessages([
|
|
|
|
|
|
...previous,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: `msg-${messageSeqRef.current}`,
|
|
|
|
|
|
role: msg.role as "user" | "assistant",
|
|
|
|
|
|
content: msg.content as string,
|
|
|
|
|
|
timestamp:
|
|
|
|
|
|
typeof msg.timestamp === "string"
|
|
|
|
|
|
? msg.timestamp
|
|
|
|
|
|
: new Date().toISOString(),
|
|
|
|
|
|
sequence: messageSeqRef.current,
|
|
|
|
|
|
},
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (msg.type === "node-active" && typeof msg.nodeId === "string") {
|
|
|
|
|
|
onNodeActiveRef.current?.(msg.nodeId);
|
2026-07-14 11:08:11 +08:00
|
|
|
|
} else if (msg.type === "workflow-variables") {
|
|
|
|
|
|
setSessionVariables(publicVariableSnapshot(msg.variables));
|
2026-07-13 06:50:23 +08:00
|
|
|
|
} else if (msg.type === "call-ended") {
|
|
|
|
|
|
endedByServerRef.current = true;
|
|
|
|
|
|
setCallEnded(true);
|
|
|
|
|
|
disconnect();
|
|
|
|
|
|
}
|
2026-07-30 19:06:03 +08:00
|
|
|
|
}, [disconnect, dispatchClientTool]);
|
2026-06-16 09:24:24 +08:00
|
|
|
|
|
2026-07-07 19:41:45 +08:00
|
|
|
|
const connect = useCallback(async (options: ConnectOptions = {}) => {
|
2026-07-13 06:50:23 +08:00
|
|
|
|
if (startingRef.current || transportRef.current) return;
|
2026-06-10 10:17:46 +08:00
|
|
|
|
if (!assistantId) {
|
|
|
|
|
|
setError("请先保存助手,再开始语音预览。");
|
|
|
|
|
|
setStatus("failed");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-13 06:50:23 +08:00
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
|
startingRef.current = true;
|
2026-08-03 12:30:49 +08:00
|
|
|
|
const generation = connectionGenerationRef.current + 1;
|
|
|
|
|
|
connectionGenerationRef.current = generation;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
setStatus("connecting");
|
2026-06-10 10:17:46 +08:00
|
|
|
|
setError(null);
|
2026-06-10 12:36:18 +08:00
|
|
|
|
setMicWarning(null);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
setMessages([]);
|
2026-07-14 11:08:11 +08:00
|
|
|
|
setSessionVariables(publicVariableSnapshot(options.dynamicVariables ?? {}));
|
2026-07-13 08:01:39 +08:00
|
|
|
|
pendingAssistantTurnsRef.current.clear();
|
2026-07-10 22:48:40 +08:00
|
|
|
|
setCallEnded(false);
|
2026-06-16 09:24:24 +08:00
|
|
|
|
endedByServerRef.current = false;
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-08-03 12:30:49 +08:00
|
|
|
|
await cleanupPromiseRef.current;
|
|
|
|
|
|
if (connectionGenerationRef.current !== generation) return;
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const iceServers = await webrtcApi
|
|
|
|
|
|
.iceServers()
|
|
|
|
|
|
.then((response) => response.iceServers)
|
|
|
|
|
|
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current !== generation) return;
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const transport = new AppSmallWebRTCTransport({ iceServers });
|
|
|
|
|
|
transportRef.current = transport;
|
|
|
|
|
|
transport.onAppMessage = handleAppMessage;
|
2026-06-10 12:36:18 +08:00
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const callbacks: NonNullable<PipecatClientOptions["callbacks"]> = {
|
|
|
|
|
|
onConnected: () => setStatus("connected"),
|
|
|
|
|
|
onDisconnected: () => {
|
|
|
|
|
|
if (transportRef.current !== transport) return;
|
|
|
|
|
|
if (endedByServerRef.current) disconnect();
|
|
|
|
|
|
else fail("WebRTC 连接已断开。");
|
|
|
|
|
|
},
|
|
|
|
|
|
onTrackStarted: (track) => {
|
|
|
|
|
|
if (track.kind !== "audio") return;
|
|
|
|
|
|
const stream = new MediaStream([track]);
|
|
|
|
|
|
setRemoteStream(stream);
|
2026-06-11 10:51:08 +08:00
|
|
|
|
if (audioRef.current) {
|
2026-07-13 06:50:23 +08:00
|
|
|
|
audioRef.current.srcObject = stream;
|
2026-07-10 11:09:44 +08:00
|
|
|
|
void applyOutputDevice(selectedOutputDeviceIdRef.current).then(() =>
|
|
|
|
|
|
audioRef.current?.play().catch(() => {}),
|
|
|
|
|
|
);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
}
|
2026-07-13 06:50:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
onDeviceError: (deviceError) => {
|
|
|
|
|
|
setMicWarning(deviceError.message || "无法访问麦克风。已尝试继续连接。");
|
|
|
|
|
|
},
|
2026-07-13 07:05:06 +08:00
|
|
|
|
onCamUpdated: () => {
|
|
|
|
|
|
const track = transport.tracks().local.video;
|
|
|
|
|
|
setVideoStream(track ? new MediaStream([track]) : null);
|
|
|
|
|
|
},
|
2026-07-13 06:50:23 +08:00
|
|
|
|
onTransportStateChanged: (nextState) => {
|
|
|
|
|
|
if (nextState === "connected" || nextState === "ready") {
|
|
|
|
|
|
setStatus("connected");
|
|
|
|
|
|
} else if (nextState === "error") {
|
|
|
|
|
|
fail("WebRTC 连接失败。");
|
2026-07-10 15:25:26 +08:00
|
|
|
|
}
|
2026-07-13 06:50:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
transport.initialize(
|
|
|
|
|
|
{
|
|
|
|
|
|
transport,
|
|
|
|
|
|
enableMic: true,
|
|
|
|
|
|
enableCam: Boolean(options.visionEnabled),
|
|
|
|
|
|
callbacks,
|
|
|
|
|
|
},
|
|
|
|
|
|
() => {},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await transport.initDevices();
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current !== generation) return;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
if (selectedDeviceIdRef.current) {
|
|
|
|
|
|
await transport.updateMic(selectedDeviceIdRef.current);
|
|
|
|
|
|
}
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current !== generation) return;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const localAudio = transport.tracks().local.audio;
|
2026-07-13 07:05:06 +08:00
|
|
|
|
const localVideo = transport.tracks().local.video;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
setLocalStream(localAudio ? new MediaStream([localAudio]) : null);
|
2026-07-13 07:05:06 +08:00
|
|
|
|
setVideoStream(localVideo ? new MediaStream([localVideo]) : null);
|
2026-07-13 06:50:23 +08:00
|
|
|
|
void refreshDevices();
|
|
|
|
|
|
|
|
|
|
|
|
const request = new Request(`${API_BASE}/api/webrtc/offer`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"X-Pipecat-Assistant-ID": assistantId,
|
|
|
|
|
|
"X-Pipecat-Dynamic-Variables": encodeHeaderJson(
|
|
|
|
|
|
options.dynamicVariables ?? {},
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
});
|
|
|
|
|
|
await transport.connect({
|
|
|
|
|
|
webrtcRequestParams: {
|
|
|
|
|
|
endpoint: request,
|
|
|
|
|
|
requestData: {
|
|
|
|
|
|
assistant_id: assistantId,
|
|
|
|
|
|
vision_enabled: Boolean(options.visionEnabled),
|
|
|
|
|
|
dynamic_variables: options.dynamicVariables ?? {},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current !== generation) return;
|
2026-07-13 06:50:23 +08:00
|
|
|
|
transport.sendAppMessage({ type: "client-ready" });
|
|
|
|
|
|
setStatus("connected");
|
2026-06-10 10:17:46 +08:00
|
|
|
|
} catch (connectionError) {
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current === generation) {
|
|
|
|
|
|
fail(errorMessage(connectionError, "无法连接语音服务。"));
|
|
|
|
|
|
}
|
2026-06-10 10:17:46 +08:00
|
|
|
|
} finally {
|
2026-08-03 12:30:49 +08:00
|
|
|
|
if (connectionGenerationRef.current === generation) {
|
|
|
|
|
|
startingRef.current = false;
|
|
|
|
|
|
}
|
2026-06-10 10:17:46 +08:00
|
|
|
|
}
|
2026-07-10 22:48:40 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
assistantId,
|
|
|
|
|
|
applyOutputDevice,
|
|
|
|
|
|
disconnect,
|
2026-07-13 06:50:23 +08:00
|
|
|
|
fail,
|
|
|
|
|
|
handleAppMessage,
|
2026-07-10 22:48:40 +08:00
|
|
|
|
refreshDevices,
|
|
|
|
|
|
]);
|
2026-06-10 10:17:46 +08:00
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const replaceVideoStream = useCallback(async (videoStream: MediaStream | null) => {
|
|
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
const deviceId = videoStream?.getVideoTracks()[0]?.getSettings().deviceId;
|
|
|
|
|
|
if (transport && deviceId) await transport.updateCam(deviceId);
|
|
|
|
|
|
}, []);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
|
2026-07-13 07:05:06 +08:00
|
|
|
|
const selectCamera = useCallback((deviceId: string) => {
|
|
|
|
|
|
transportRef.current?.updateCam(deviceId);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const selectDevice = useCallback(async (deviceId: string) => {
|
|
|
|
|
|
setSelectedDeviceId(deviceId);
|
|
|
|
|
|
selectedDeviceIdRef.current = deviceId;
|
|
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!transport) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await transport.updateMic(deviceId);
|
|
|
|
|
|
const audio = transport.tracks().local.audio;
|
|
|
|
|
|
setLocalStream(audio ? new MediaStream([audio]) : null);
|
|
|
|
|
|
setMicWarning(null);
|
|
|
|
|
|
} catch (deviceError) {
|
|
|
|
|
|
setMicWarning(errorMessage(deviceError, "无法切换麦克风。"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const selectOutputDevice = useCallback((deviceId: string) => {
|
|
|
|
|
|
setSelectedOutputDeviceId(deviceId);
|
|
|
|
|
|
selectedOutputDeviceIdRef.current = deviceId;
|
|
|
|
|
|
void applyOutputDevice(deviceId);
|
|
|
|
|
|
}, [applyOutputDevice]);
|
2026-07-10 15:25:26 +08:00
|
|
|
|
|
2026-07-13 08:01:39 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (status !== "connected") return;
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
const sample = async () => {
|
|
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!transport) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const report = await transport.connectionStats();
|
|
|
|
|
|
const metrics = report ? readNetworkMetrics(report) : null;
|
|
|
|
|
|
if (cancelled || transportRef.current !== transport) return;
|
|
|
|
|
|
if (!metrics) {
|
|
|
|
|
|
setNetworkQuality("unknown");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setNetworkQuality(
|
|
|
|
|
|
classifyNetworkQuality(metrics, networkStatsRef.current),
|
|
|
|
|
|
);
|
|
|
|
|
|
networkStatsRef.current = {
|
|
|
|
|
|
packetsSent: metrics.packetsSent,
|
|
|
|
|
|
packetsLost: metrics.packetsLost,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) setNetworkQuality("unknown");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
void sample();
|
|
|
|
|
|
const interval = window.setInterval(() => void sample(), NETWORK_SAMPLE_INTERVAL_MS);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
window.clearInterval(interval);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [status]);
|
|
|
|
|
|
|
2026-06-10 15:11:34 +08:00
|
|
|
|
const sendText = useCallback((text: string): boolean => {
|
|
|
|
|
|
const trimmed = text.trim();
|
2026-07-13 06:50:23 +08:00
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!trimmed || !transport || transport.state !== "connected") return false;
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const inputId = newInputId();
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "user-input",
|
|
|
|
|
|
schema_version: 1,
|
|
|
|
|
|
input_id: inputId,
|
|
|
|
|
|
parts: [{ type: "input_text", text: trimmed }],
|
|
|
|
|
|
options: { run_immediately: true, interrupt: true },
|
|
|
|
|
|
});
|
2026-06-10 15:11:34 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const sendUserInput = useCallback(
|
|
|
|
|
|
(
|
|
|
|
|
|
parts: UserInputPart[],
|
|
|
|
|
|
options: { runImmediately?: boolean; interrupt?: boolean } = {},
|
|
|
|
|
|
): Promise<UserInputResult> => {
|
|
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!transport || transport.state !== "connected") {
|
|
|
|
|
|
return Promise.reject(new Error("当前未连接语音服务"));
|
|
|
|
|
|
}
|
|
|
|
|
|
const inputId = newInputId();
|
|
|
|
|
|
return new Promise<UserInputResult>((resolve, reject) => {
|
|
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
|
|
|
|
pendingUserInputsRef.current.delete(inputId);
|
|
|
|
|
|
reject(new Error("等待用户输入处理结果超时"));
|
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
pendingUserInputsRef.current.set(inputId, { resolve, reject, timeout });
|
|
|
|
|
|
try {
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "user-input",
|
|
|
|
|
|
schema_version: 1,
|
|
|
|
|
|
input_id: inputId,
|
|
|
|
|
|
parts,
|
|
|
|
|
|
options: {
|
|
|
|
|
|
run_immediately: options.runImmediately ?? true,
|
|
|
|
|
|
interrupt: options.interrupt ?? true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (sendError) {
|
|
|
|
|
|
window.clearTimeout(timeout);
|
|
|
|
|
|
pendingUserInputsRef.current.delete(inputId);
|
|
|
|
|
|
reject(
|
|
|
|
|
|
new Error(errorMessage(sendError, "发送用户输入失败")),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-31 00:01:01 +08:00
|
|
|
|
const updateSession = useCallback(
|
|
|
|
|
|
({
|
|
|
|
|
|
dynamicVariables,
|
|
|
|
|
|
}: SessionUpdateRequest): Promise<SessionUpdateResult> => {
|
|
|
|
|
|
if (Object.keys(dynamicVariables).length === 0) {
|
|
|
|
|
|
return Promise.reject(new Error("至少需要更新一个动态变量"));
|
|
|
|
|
|
}
|
|
|
|
|
|
const transport = transportRef.current;
|
|
|
|
|
|
if (!transport || transport.state !== "connected") {
|
|
|
|
|
|
return Promise.reject(new Error("当前未连接语音服务"));
|
|
|
|
|
|
}
|
|
|
|
|
|
const updateId = newUpdateId();
|
|
|
|
|
|
return new Promise<SessionUpdateResult>((resolve, reject) => {
|
|
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
|
|
|
|
pendingSessionUpdatesRef.current.delete(updateId);
|
|
|
|
|
|
reject(new Error("等待会话状态更新结果超时"));
|
|
|
|
|
|
}, SESSION_UPDATE_TIMEOUT_MS);
|
|
|
|
|
|
pendingSessionUpdatesRef.current.set(updateId, {
|
|
|
|
|
|
resolve,
|
|
|
|
|
|
reject,
|
|
|
|
|
|
timeout,
|
|
|
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
|
|
|
transport.sendAppMessage({
|
|
|
|
|
|
type: "session-update",
|
|
|
|
|
|
schema_version: 1,
|
|
|
|
|
|
update_id: updateId,
|
|
|
|
|
|
dynamic_variables: dynamicVariables,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (sendError) {
|
|
|
|
|
|
window.clearTimeout(timeout);
|
|
|
|
|
|
pendingSessionUpdatesRef.current.delete(updateId);
|
|
|
|
|
|
reject(new Error(errorMessage(sendError, "发送会话状态更新失败")));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-30 19:06:03 +08:00
|
|
|
|
const registerClientTool = useCallback(
|
2026-08-03 14:00:33 +08:00
|
|
|
|
(
|
|
|
|
|
|
definition: ClientToolDefinition,
|
|
|
|
|
|
handler: ClientToolHandler,
|
|
|
|
|
|
): (() => void) => {
|
|
|
|
|
|
const registeredTool = { definition, handler };
|
|
|
|
|
|
clientToolHandlersRef.current.set(definition.name, registeredTool);
|
|
|
|
|
|
setClientTools(
|
|
|
|
|
|
[...clientToolHandlersRef.current.values()].map(
|
|
|
|
|
|
(item) => item.definition,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-07-30 19:06:03 +08:00
|
|
|
|
return () => {
|
2026-08-03 14:00:33 +08:00
|
|
|
|
if (
|
|
|
|
|
|
clientToolHandlersRef.current.get(definition.name) === registeredTool
|
|
|
|
|
|
) {
|
|
|
|
|
|
clientToolHandlersRef.current.delete(definition.name);
|
|
|
|
|
|
setClientTools(
|
|
|
|
|
|
[...clientToolHandlersRef.current.values()].map(
|
|
|
|
|
|
(item) => item.definition,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-07-30 19:06:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
|
useEffect(() => releaseResources, [releaseResources]);
|
|
|
|
|
|
|
2026-06-10 12:36:18 +08:00
|
|
|
|
return {
|
|
|
|
|
|
status,
|
|
|
|
|
|
error,
|
|
|
|
|
|
micWarning,
|
|
|
|
|
|
localStream,
|
2026-07-13 07:05:06 +08:00
|
|
|
|
videoStream,
|
2026-06-11 10:51:08 +08:00
|
|
|
|
remoteStream,
|
2026-06-10 15:11:34 +08:00
|
|
|
|
messages,
|
2026-07-14 11:08:11 +08:00
|
|
|
|
sessionVariables,
|
2026-08-03 14:00:33 +08:00
|
|
|
|
clientTools,
|
2026-07-10 22:48:40 +08:00
|
|
|
|
callEnded,
|
2026-07-10 15:25:26 +08:00
|
|
|
|
networkQuality,
|
2026-06-10 15:26:33 +08:00
|
|
|
|
audioInputs,
|
2026-07-10 11:09:44 +08:00
|
|
|
|
audioOutputs,
|
2026-06-10 15:26:33 +08:00
|
|
|
|
selectedDeviceId,
|
2026-07-10 11:09:44 +08:00
|
|
|
|
selectedOutputDeviceId,
|
2026-06-10 15:26:33 +08:00
|
|
|
|
setSelectedDeviceId,
|
2026-06-14 21:02:03 +08:00
|
|
|
|
selectDevice,
|
2026-07-10 11:09:44 +08:00
|
|
|
|
selectOutputDevice,
|
|
|
|
|
|
supportsOutputSelection,
|
2026-06-10 15:11:34 +08:00
|
|
|
|
sendText,
|
2026-07-30 19:06:03 +08:00
|
|
|
|
sendUserInput,
|
2026-07-31 00:01:01 +08:00
|
|
|
|
updateSession,
|
2026-07-30 19:06:03 +08:00
|
|
|
|
registerClientTool,
|
2026-06-10 12:36:18 +08:00
|
|
|
|
connect,
|
2026-07-07 19:41:45 +08:00
|
|
|
|
replaceVideoStream,
|
2026-07-13 07:05:06 +08:00
|
|
|
|
selectCamera,
|
2026-06-10 12:36:18 +08:00
|
|
|
|
disconnect,
|
|
|
|
|
|
audioRef,
|
|
|
|
|
|
};
|
2026-06-10 10:17:46 +08:00
|
|
|
|
}
|
2026-07-06 14:08:15 +08:00
|
|
|
|
|
|
|
|
|
|
export type VoicePreview = ReturnType<typeof useVoicePreview>;
|