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

View File

@@ -18,6 +18,7 @@ import {
Sparkles, Sparkles,
Video, Video,
Waves, Waves,
Wrench,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -49,6 +50,7 @@ import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import { import {
useVoicePreview, useVoicePreview,
type ChatMessage, type ChatMessage,
type ClientToolDefinition,
type VoicePreview, type VoicePreview,
type VoicePreviewStatus, type VoicePreviewStatus,
} from "@/hooks/use-voice-preview"; } from "@/hooks/use-voice-preview";
@@ -216,6 +218,7 @@ export function DebugDrawer({
/> />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<ClientToolsPopover tools={preview.clientTools} />
<CallPreviewLink assistantId={assistantId} /> <CallPreviewLink assistantId={assistantId} />
{dynamicVariablesEnabled && ( {dynamicVariablesEnabled && (
<DynamicVariableValuesPopover <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({ function DynamicVariableValuesPopover({
entries, entries,
values, values,
@@ -629,7 +757,7 @@ function DebugVoicePanel({
dynamicVariables: Record<string, string | number | boolean>; dynamicVariables: Record<string, string | number | boolean>;
dynamicVariablesError: string; dynamicVariablesError: string;
}) { }) {
const photoCapture = usePhotoCaptureTool(preview); const photoCapture = usePhotoCaptureTool(preview, vision);
const { const {
status, status,
error, error,

View File

@@ -15,6 +15,37 @@ import type { VoicePreview } from "@/hooks/use-voice-preview";
const SHOW_MESSAGE_TOOL = "show_message"; const SHOW_MESSAGE_TOOL = "show_message";
const MAX_ACTIONS = 5; 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"; type MessageActionStyle = "primary" | "secondary" | "danger";
@@ -142,7 +173,9 @@ export function ClientMessageDialog({
}, []); }, []);
useEffect(() => { useEffect(() => {
const unregister = registerClientTool(SHOW_MESSAGE_TOOL, (argumentsValue) => { const unregister = registerClientTool(
SHOW_MESSAGE_DEFINITION,
(argumentsValue) => {
if (pendingRef.current) { if (pendingRef.current) {
throw new Error("已有消息弹窗正在等待用户操作"); throw new Error("已有消息弹窗正在等待用户操作");
} }
@@ -151,7 +184,8 @@ export function ClientMessageDialog({
pendingRef.current = { resolve, reject }; pendingRef.current = { resolve, reject };
setMessage(nextMessage); setMessage(nextMessage);
}); });
}); },
);
return () => { return () => {
unregister(); unregister();

View File

@@ -5,32 +5,47 @@ import { useCallback, useEffect, useState } from "react";
import type { VoicePreview } from "@/hooks/use-voice-preview"; import type { VoicePreview } from "@/hooks/use-voice-preview";
const PHOTO_BUTTON_TOOL = "set_photo_button_visible"; const PHOTO_BUTTON_TOOL = "set_photo_button_visible";
const PHOTO_BUTTON_DEFINITION = {
name: PHOTO_BUTTON_TOOL,
label: "控制拍照按钮",
description: "显示或隐藏客户端的拍照按钮。",
parameters: [
{
name: "visible",
type: "boolean",
required: true,
description: "是否显示拍照按钮。",
},
],
} as const;
export function usePhotoCaptureTool(preview: VoicePreview) { export function usePhotoCaptureTool(preview: VoicePreview, enabled = true) {
const { registerClientTool, sendUserInput, status } = preview; const { registerClientTool, sendUserInput, status } = preview;
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [capturing, setCapturing] = useState(false); const [capturing, setCapturing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect( useEffect(() => {
() => if (!enabled) return;
registerClientTool(PHOTO_BUTTON_TOOL, ({ visible: nextValue }) => { return registerClientTool(
PHOTO_BUTTON_DEFINITION,
({ visible: nextValue }) => {
if (typeof nextValue !== "boolean") { if (typeof nextValue !== "boolean") {
throw new Error("visible 参数必须是布尔值"); throw new Error("visible 参数必须是布尔值");
} }
setVisible(nextValue); setVisible(nextValue);
setError(null); setError(null);
return { visible: nextValue }; return { visible: nextValue };
}), },
[registerClientTool],
); );
}, [enabled, registerClientTool]);
useEffect(() => { useEffect(() => {
if (status === "connected") return; if (enabled && status === "connected") return;
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setVisible(false); setVisible(false);
setCapturing(false); setCapturing(false);
}, [status]); }, [enabled, status]);
const capture = useCallback(async () => { const capture = useCallback(async () => {
if (capturing || status !== "connected") return; if (capturing || status !== "connected") return;

View File

@@ -71,6 +71,23 @@ export type SessionUpdateResult = {
export type ClientToolHandler = ( export type ClientToolHandler = (
argumentsValue: Record<string, unknown>, argumentsValue: Record<string, unknown>,
) => unknown | Promise<unknown>; ) => unknown | Promise<unknown>;
export type ClientToolDefinition = {
name: string;
label: string;
description: string;
parameters: readonly ClientToolParameter[];
};
export type ClientToolParameter = {
name: string;
type: string;
required: boolean;
description: string;
};
type RegisteredClientTool = {
definition: ClientToolDefinition;
handler: ClientToolHandler;
};
type PendingUserInput = { type PendingUserInput = {
resolve: (result: UserInputResult) => void; resolve: (result: UserInputResult) => void;
@@ -276,6 +293,7 @@ export function useVoicePreview(
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]); const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState(""); const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
const [clientTools, setClientTools] = useState<ClientToolDefinition[]>([]);
const transportRef = useRef<AppSmallWebRTCTransport | null>(null); const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve()); const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
@@ -290,7 +308,9 @@ export function useVoicePreview(
const pendingSessionUpdatesRef = useRef( const pendingSessionUpdatesRef = useRef(
new Map<string, PendingSessionUpdate>(), new Map<string, PendingSessionUpdate>(),
); );
const clientToolHandlersRef = useRef(new Map<string, ClientToolHandler>()); const clientToolHandlersRef = useRef(
new Map<string, RegisteredClientTool>(),
);
const endedByServerRef = useRef(false); const endedByServerRef = useRef(false);
const selectedDeviceIdRef = useRef(""); const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef(""); const selectedOutputDeviceIdRef = useRef("");
@@ -396,10 +416,10 @@ export function useVoicePreview(
const waitForResponse = msg.wait_for_response !== false; const waitForResponse = msg.wait_for_response !== false;
if (!toolCallId || !functionName) return; if (!toolCallId || !functionName) return;
const handler = clientToolHandlersRef.current.get(functionName); const registeredTool = clientToolHandlersRef.current.get(functionName);
const transport = transportRef.current; const transport = transportRef.current;
if (!transport || transport.state !== "connected") return; if (!transport || transport.state !== "connected") return;
if (!handler) { if (!registeredTool) {
if (waitForResponse) { if (waitForResponse) {
transport.sendAppMessage({ transport.sendAppMessage({
type: "client-tool-result", type: "client-tool-result",
@@ -418,7 +438,7 @@ export function useVoicePreview(
!Array.isArray(msg.arguments) !Array.isArray(msg.arguments)
? (msg.arguments as Record<string, unknown>) ? (msg.arguments as Record<string, unknown>)
: {}; : {};
const data = await handler(argumentsValue); const data = await registeredTool.handler(argumentsValue);
if (transportRef.current !== transport) return; if (transportRef.current !== transport) return;
if (!waitForResponse) return; if (!waitForResponse) return;
transport.sendAppMessage({ transport.sendAppMessage({
@@ -889,11 +909,27 @@ export function useVoicePreview(
); );
const registerClientTool = useCallback( const registerClientTool = useCallback(
(functionName: string, handler: ClientToolHandler): (() => void) => { (
clientToolHandlersRef.current.set(functionName, handler); definition: ClientToolDefinition,
handler: ClientToolHandler,
): (() => void) => {
const registeredTool = { definition, handler };
clientToolHandlersRef.current.set(definition.name, registeredTool);
setClientTools(
[...clientToolHandlersRef.current.values()].map(
(item) => item.definition,
),
);
return () => { return () => {
if (clientToolHandlersRef.current.get(functionName) === handler) { if (
clientToolHandlersRef.current.delete(functionName); clientToolHandlersRef.current.get(definition.name) === registeredTool
) {
clientToolHandlersRef.current.delete(definition.name);
setClientTools(
[...clientToolHandlersRef.current.values()].map(
(item) => item.definition,
),
);
} }
}; };
}, },
@@ -911,6 +947,7 @@ export function useVoicePreview(
remoteStream, remoteStream,
messages, messages,
sessionVariables, sessionVariables,
clientTools,
callEnded, callEnded,
networkQuality, networkQuality,
audioInputs, audioInputs,