feat: add session updates and message dialogs
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user