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

@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
import { ClientMessageDialog } from "@/components/client-message-dialog";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -684,6 +685,7 @@ function DebugVoicePanel({
<div className="flex min-h-0 flex-1 flex-col">
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
<audio ref={audioRef} autoPlay playsInline className="hidden" />
<ClientMessageDialog preview={preview} />
{vision && !showIdleHub ? (
<DebugVisionWorkspace
view={view}

View File

@@ -0,0 +1,202 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { VoicePreview } from "@/hooks/use-voice-preview";
const SHOW_MESSAGE_TOOL = "show_message";
const MAX_ACTIONS = 5;
type MessageActionStyle = "primary" | "secondary" | "danger";
type MessageAction = {
id: string;
label: string;
style: MessageActionStyle;
};
type MessageState = {
title: string;
message: string;
actions: MessageAction[];
dismissible: boolean;
};
type PendingMessage = {
resolve: (result: { action: string }) => void;
reject: (error: Error) => void;
};
function requiredString(
value: unknown,
field: string,
maxLength: number,
): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} 必须是非空字符串`);
}
const normalized = value.trim();
if (normalized.length > maxLength) {
throw new Error(`${field} 最多允许 ${maxLength} 个字符`);
}
return normalized;
}
function normalizeAction(value: unknown, index: number): MessageAction {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`actions[${index}] 格式不正确`);
}
const action = value as Record<string, unknown>;
const rawStyle = action.style ?? "secondary";
if (
rawStyle !== "primary" &&
rawStyle !== "secondary" &&
rawStyle !== "danger"
) {
throw new Error(`actions[${index}].style 不受支持`);
}
return {
id: requiredString(action.id, `actions[${index}].id`, 64),
label: requiredString(action.label, `actions[${index}].label`, 40),
style: rawStyle,
};
}
function normalizeMessage(argumentsValue: Record<string, unknown>): MessageState {
const rawActions = argumentsValue.actions;
if (
rawActions !== undefined &&
(!Array.isArray(rawActions) || rawActions.length > MAX_ACTIONS)
) {
throw new Error(`actions 必须是数组且最多包含 ${MAX_ACTIONS}`);
}
const actions =
Array.isArray(rawActions) && rawActions.length > 0
? rawActions.map(normalizeAction)
: [
{
id: "acknowledged",
label: "知道了",
style: "primary" as const,
},
];
if (new Set(actions.map((action) => action.id)).size !== actions.length) {
throw new Error("actions.id 不能重复");
}
if (
argumentsValue.dismissible !== undefined &&
typeof argumentsValue.dismissible !== "boolean"
) {
throw new Error("dismissible 必须是布尔值");
}
return {
title:
argumentsValue.title === undefined
? "提示"
: requiredString(argumentsValue.title, "title", 120),
message: requiredString(argumentsValue.message, "message", 2_000),
actions,
dismissible: argumentsValue.dismissible !== false,
};
}
function actionVariant(style: MessageActionStyle) {
if (style === "primary") return "default" as const;
if (style === "danger") return "destructive" as const;
return "outline" as const;
}
/**
* Renders messages requested by the agent through the generic Client Tool
* channel. The tool result is held until the user chooses an action.
*/
export function ClientMessageDialog({ preview }: { preview: VoicePreview }) {
const { registerClientTool, status } = preview;
const [message, setMessage] = useState<MessageState | null>(null);
const pendingRef = useRef<PendingMessage | null>(null);
const complete = useCallback((action: string) => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
setMessage(null);
pending.resolve({ action });
}, []);
useEffect(() => {
const unregister = registerClientTool(SHOW_MESSAGE_TOOL, (argumentsValue) => {
if (pendingRef.current) {
throw new Error("已有消息弹窗正在等待用户操作");
}
const nextMessage = normalizeMessage(argumentsValue);
return new Promise<{ action: string }>((resolve, reject) => {
pendingRef.current = { resolve, reject };
setMessage(nextMessage);
});
});
return () => {
unregister();
const pending = pendingRef.current;
pendingRef.current = null;
pending?.reject(new Error("消息弹窗已关闭"));
};
}, [registerClientTool]);
useEffect(() => {
if (status === "connected" || !pendingRef.current) return;
const pending = pendingRef.current;
pendingRef.current = null;
pending.reject(new Error("会话已结束"));
setMessage(null);
}, [status]);
return (
<Dialog
open={message !== null}
onOpenChange={(open) => {
if (!open && message?.dismissible) complete("dismissed");
}}
>
<DialogContent
className="gap-5 sm:max-w-md"
showCloseButton={message?.dismissible ?? true}
onEscapeKeyDown={(event) => {
if (!message?.dismissible) event.preventDefault();
}}
onInteractOutside={(event) => {
if (!message?.dismissible) event.preventDefault();
}}
>
<DialogHeader className="text-left">
<DialogTitle>{message?.title}</DialogTitle>
<DialogDescription className="whitespace-pre-wrap leading-6 text-body">
{message?.message}
</DialogDescription>
</DialogHeader>
<DialogFooter>
{message?.actions.map((action) => (
<Button
key={action.id}
type="button"
variant={actionVariant(action.style)}
onClick={() => complete(action.id)}
>
{action.label}
</Button>
))}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -980,7 +980,7 @@ function ClientToolFields({
)}
<p className="text-xs leading-5 text-muted-foreground">
handler
set_photo_button_visible
set_photo_button_visible show_message
</p>
</div>
);

View File

@@ -30,6 +30,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ClientMessageDialog } from "@/components/client-message-dialog";
import { useCameraPreview } from "@/hooks/use-camera-preview";
import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import {
@@ -483,6 +484,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
return (
<main className="flex h-dvh w-full items-center justify-center overflow-hidden bg-black lg:p-4">
<audio ref={audioRef} autoPlay playsInline className="hidden" />
<ClientMessageDialog preview={preview} />
<div
data-testid="mobile-call-viewport"
className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white lg:h-[calc(100dvh-2rem)] lg:min-h-0 lg:w-auto lg:aspect-[9/16] lg:rounded-[2rem] lg:ring-1 lg:ring-white/15 lg:shadow-2xl"

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,