feat: add session updates and message dialogs

This commit is contained in:
Xin Wang
2026-07-31 00:01:01 +08:00
parent 913435785e
commit c2f0f5eb04
14 changed files with 850 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
export type NetworkQuality = "unknown" | "good" | "fair" | "poor";
const NETWORK_SAMPLE_INTERVAL_MS = 2_000;
const SESSION_UPDATE_TIMEOUT_MS = 10_000;
type NetworkStatsSample = {
packetsSent: number;
@@ -46,7 +47,7 @@ export type ChatMessage = {
};
type AppMessage = Record<string, unknown> & { type?: string };
type DynamicVariableValue = string | number | boolean;
export type DynamicVariableValue = string | number | boolean;
export type UserInputPart =
| { type: "input_text"; text: string }
| {
@@ -57,6 +58,15 @@ export type UserInputResult = {
inputId: string;
status: "accepted";
};
export type SessionUpdateRequest = {
dynamicVariables: Record<string, DynamicVariableValue>;
};
export type SessionUpdateResult = {
updateId: string;
status: "accepted";
changed: string[];
dynamicVariables: Record<string, DynamicVariableValue>;
};
export type ClientToolHandler = (
argumentsValue: Record<string, unknown>,
) => unknown | Promise<unknown>;
@@ -67,6 +77,12 @@ type PendingUserInput = {
timeout: number;
};
type PendingSessionUpdate = {
resolve: (result: SessionUpdateResult) => void;
reject: (error: Error) => void;
timeout: number;
};
function publicVariableSnapshot(
value: unknown,
): Record<string, DynamicVariableValue> {
@@ -142,6 +158,13 @@ function newInputId(): string {
return `input_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function newUpdateId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `update_${crypto.randomUUID()}`;
}
return `update_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function readNetworkMetrics(report: RTCStatsReport): NetworkMetrics | null {
const outbound: Partial<Record<"audio" | "video", number>> = {};
const lost: Partial<Record<"audio" | "video", number>> = {};
@@ -246,6 +269,9 @@ export function useVoicePreview(
new Map<string, { timestamp: string }>(),
);
const pendingUserInputsRef = useRef(new Map<string, PendingUserInput>());
const pendingSessionUpdatesRef = useRef(
new Map<string, PendingSessionUpdate>(),
);
const clientToolHandlersRef = useRef(new Map<string, ClientToolHandler>());
const endedByServerRef = useRef(false);
const selectedDeviceIdRef = useRef("");
@@ -306,6 +332,11 @@ export function useVoicePreview(
reject(new Error("连接已关闭,用户输入未完成"));
});
pendingUserInputsRef.current.clear();
pendingSessionUpdatesRef.current.forEach(({ reject, timeout }) => {
window.clearTimeout(timeout);
reject(new Error("连接已关闭,会话状态更新未完成"));
});
pendingSessionUpdatesRef.current.clear();
networkStatsRef.current = null;
onNodeActiveRef.current?.(null);
}, []);
@@ -405,6 +436,33 @@ export function useVoicePreview(
),
);
}
} 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 : "会话状态更新失败",
),
);
}
} else if (
msg.type === "assistant-text-start" &&
typeof msg.turn_id === "string"
@@ -754,6 +812,45 @@ export function useVoicePreview(
[],
);
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, "发送会话状态更新失败")));
}
});
},
[],
);
const registerClientTool = useCallback(
(functionName: string, handler: ClientToolHandler): (() => void) => {
clientToolHandlersRef.current.set(functionName, handler);
@@ -789,6 +886,7 @@ export function useVoicePreview(
supportsOutputSelection,
sendText,
sendUserInput,
updateSession,
registerClientTool,
connect,
replaceVideoStream,