feat: show client tools in debug preview

This commit is contained in:
Xin Wang
2026-08-03 14:00:33 +08:00
parent ef434c80b2
commit dd40bfb90a
4 changed files with 242 additions and 28 deletions
@@ -18,6 +18,7 @@ import {
Sparkles,
Video,
Waves,
Wrench,
X,
} from "lucide-react";
@@ -49,6 +50,7 @@ import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import {
useVoicePreview,
type ChatMessage,
type ClientToolDefinition,
type VoicePreview,
type VoicePreviewStatus,
} from "@/hooks/use-voice-preview";
@@ -216,6 +218,7 @@ export function DebugDrawer({
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<ClientToolsPopover tools={preview.clientTools} />
<CallPreviewLink assistantId={assistantId} />
{dynamicVariablesEnabled && (
<DynamicVariableValuesPopover
@@ -284,6 +287,131 @@ export function DebugDrawer({
);
}
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);
const copyToolName = useCallback(async (toolName: string) => {
await navigator.clipboard.writeText(toolName);
setCopiedToolName(toolName);
window.setTimeout(
() =>
setCopiedToolName((current) =>
current === toolName ? null : current,
),
1600,
);
}, []);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看调试端支持的 Client Tools"
title="调试端支持的 Client Tools"
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
>
<Wrench size={15} />
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
{tools.length}
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className="w-80 space-y-3 rounded-2xl p-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Wrench size={15} />
Client Tools
</div>
<p className="text-xs leading-5 text-muted-foreground">
当前调试客户端已注册、能够响应的工具。
</p>
</div>
{tools.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-4 text-center text-xs text-muted-foreground">
当前没有可用的 Client Tool
</div>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{tools.map((tool) => (
<div
key={tool.name}
className="rounded-xl border border-hairline bg-canvas-soft p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-foreground">
{tool.label}
</div>
<Badge
variant="secondary"
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
>
可用
</Badge>
</div>
<button
type="button"
onClick={() => void copyToolName(tool.name)}
className="mt-1 flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-foreground transition-colors hover:bg-surface-strong"
aria-label={`复制工具名 ${tool.name}`}
title="点击复制工具名"
>
<code className="min-w-0 break-all font-mono text-[11px]">
{tool.name}
</code>
{copiedToolName === tool.name ? (
<Check size={13} className="shrink-0 text-success" />
) : (
<Copy size={13} className="shrink-0 text-muted-soft" />
)}
</button>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
{tool.description}
</p>
<div className="mt-2 border-t border-hairline pt-2">
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-soft">
参数
</div>
<div className="space-y-2">
{tool.parameters.map((parameter) => (
<div key={parameter.name} className="text-[11px] leading-4">
<div className="flex flex-wrap items-center gap-1.5">
<code className="font-mono font-medium text-foreground">
{parameter.name}
</code>
<span className="rounded-full bg-surface-strong px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{parameter.type}
</span>
<span
className={
parameter.required
? "text-destructive"
: "text-muted-soft"
}
>
{parameter.required ? "必填" : "可选"}
</span>
</div>
<p className="mt-0.5 text-muted-foreground">
{parameter.description}
</p>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
);
}
function DynamicVariableValuesPopover({
entries,
values,
@@ -629,7 +757,7 @@ function DebugVoicePanel({
dynamicVariables: Record<string, string | number | boolean>;
dynamicVariablesError: string;
}) {
const photoCapture = usePhotoCaptureTool(preview);
const photoCapture = usePhotoCaptureTool(preview, vision);
const {
status,
error,
@@ -15,6 +15,37 @@ import type { VoicePreview } from "@/hooks/use-voice-preview";
const SHOW_MESSAGE_TOOL = "show_message";
const MAX_ACTIONS = 5;
const SHOW_MESSAGE_DEFINITION = {
name: SHOW_MESSAGE_TOOL,
label: "显示消息",
description: "向用户显示消息弹窗,并等待用户选择操作。",
parameters: [
{
name: "message",
type: "string",
required: true,
description: "消息正文,最多 2000 个字符。",
},
{
name: "title",
type: "string",
required: false,
description: "弹窗标题,默认“提示”,最多 120 个字符。",
},
{
name: "actions",
type: "array",
required: false,
description: "操作列表,最多 5 项;每项包含 id、label 和可选 style。",
},
{
name: "dismissible",
type: "boolean",
required: false,
description: "是否允许关闭弹窗,默认为 true。",
},
],
} as const;
type MessageActionStyle = "primary" | "secondary" | "danger";
@@ -142,16 +173,19 @@ export function ClientMessageDialog({
}, []);
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);
});
});
const unregister = registerClientTool(
SHOW_MESSAGE_DEFINITION,
(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();