Files
ai-video-fullstack/frontend/src/components/client-message-dialog.tsx
2026-08-02 00:07:35 +08:00

214 lines
5.9 KiB
TypeScript

"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,
container,
contained = false,
}: {
preview: VoicePreview;
container?: HTMLElement | null;
contained?: boolean;
}) {
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
modal={!contained}
open={message !== null}
onOpenChange={(open) => {
if (!open && message?.dismissible) complete("dismissed");
}}
>
<DialogContent
className="gap-5 sm:max-w-md"
portalContainer={container}
contained={contained}
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>
);
}