214 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|