"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; 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): 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, container, contained = false, }: { preview: VoicePreview; container?: HTMLElement | null; contained?: boolean; }) { const { registerClientTool, status } = preview; const [message, setMessage] = useState(null); const pendingRef = useRef(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 ( { if (!open && message?.dismissible) complete("dismissed"); }} > { if (!message?.dismissible) event.preventDefault(); }} onInteractOutside={(event) => { if (!message?.dismissible) event.preventDefault(); }} > {message?.title} {message?.message} {message?.actions.map((action) => ( ))} ); }