feat: add session updates and message dialogs
This commit is contained in:
@@ -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}
|
||||
|
||||
202
frontend/src/components/client-message-dialog.tsx
Normal file
202
frontend/src/components/client-message-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user