feat: show client tools in debug preview
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,16 +173,19 @@ export function ClientMessageDialog({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unregister = registerClientTool(SHOW_MESSAGE_TOOL, (argumentsValue) => {
|
const unregister = registerClientTool(
|
||||||
if (pendingRef.current) {
|
SHOW_MESSAGE_DEFINITION,
|
||||||
throw new Error("已有消息弹窗正在等待用户操作");
|
(argumentsValue) => {
|
||||||
}
|
if (pendingRef.current) {
|
||||||
const nextMessage = normalizeMessage(argumentsValue);
|
throw new Error("已有消息弹窗正在等待用户操作");
|
||||||
return new Promise<{ action: string }>((resolve, reject) => {
|
}
|
||||||
pendingRef.current = { resolve, reject };
|
const nextMessage = normalizeMessage(argumentsValue);
|
||||||
setMessage(nextMessage);
|
return new Promise<{ action: string }>((resolve, reject) => {
|
||||||
});
|
pendingRef.current = { resolve, reject };
|
||||||
});
|
setMessage(nextMessage);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unregister();
|
unregister();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user