diff --git a/frontend/src/components/assistant-editor/debug-preview.tsx b/frontend/src/components/assistant-editor/debug-preview.tsx
index 7777b4b..5d368a1 100644
--- a/frontend/src/components/assistant-editor/debug-preview.tsx
+++ b/frontend/src/components/assistant-editor/debug-preview.tsx
@@ -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({
/>
+
{dynamicVariablesEnabled && (
(null);
+
+ const copyToolName = useCallback(async (toolName: string) => {
+ await navigator.clipboard.writeText(toolName);
+ setCopiedToolName(toolName);
+ window.setTimeout(
+ () =>
+ setCopiedToolName((current) =>
+ current === toolName ? null : current,
+ ),
+ 1600,
+ );
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ Client Tools
+
+
+ 当前调试客户端已注册、能够响应的工具。
+
+
+ {tools.length === 0 ? (
+
+ 当前没有可用的 Client Tool
+
+ ) : (
+
+ {tools.map((tool) => (
+
+
+
+ {tool.label}
+
+
+ 可用
+
+
+
+
+ {tool.description}
+
+
+
+ 参数
+
+
+ {tool.parameters.map((parameter) => (
+
+
+
+ {parameter.name}
+
+
+ {parameter.type}
+
+
+ {parameter.required ? "必填" : "可选"}
+
+
+
+ {parameter.description}
+
+
+ ))}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
function DynamicVariableValuesPopover({
entries,
values,
@@ -629,7 +757,7 @@ function DebugVoicePanel({
dynamicVariables: Record;
dynamicVariablesError: string;
}) {
- const photoCapture = usePhotoCaptureTool(preview);
+ const photoCapture = usePhotoCaptureTool(preview, vision);
const {
status,
error,
diff --git a/frontend/src/components/client-message-dialog.tsx b/frontend/src/components/client-message-dialog.tsx
index a2c0260..9b3228d 100644
--- a/frontend/src/components/client-message-dialog.tsx
+++ b/frontend/src/components/client-message-dialog.tsx
@@ -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();
diff --git a/frontend/src/hooks/use-photo-capture-tool.ts b/frontend/src/hooks/use-photo-capture-tool.ts
index 1e0db9f..7343f20 100644
--- a/frontend/src/hooks/use-photo-capture-tool.ts
+++ b/frontend/src/hooks/use-photo-capture-tool.ts
@@ -5,32 +5,47 @@ import { useCallback, useEffect, useState } from "react";
import type { VoicePreview } from "@/hooks/use-voice-preview";
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 [visible, setVisible] = useState(false);
const [capturing, setCapturing] = useState(false);
const [error, setError] = useState(null);
- useEffect(
- () =>
- registerClientTool(PHOTO_BUTTON_TOOL, ({ visible: nextValue }) => {
+ useEffect(() => {
+ if (!enabled) return;
+ return registerClientTool(
+ PHOTO_BUTTON_DEFINITION,
+ ({ visible: nextValue }) => {
if (typeof nextValue !== "boolean") {
throw new Error("visible 参数必须是布尔值");
}
setVisible(nextValue);
setError(null);
return { visible: nextValue };
- }),
- [registerClientTool],
- );
+ },
+ );
+ }, [enabled, registerClientTool]);
useEffect(() => {
- if (status === "connected") return;
+ if (enabled && status === "connected") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setVisible(false);
setCapturing(false);
- }, [status]);
+ }, [enabled, status]);
const capture = useCallback(async () => {
if (capturing || status !== "connected") return;
diff --git a/frontend/src/hooks/use-voice-preview.ts b/frontend/src/hooks/use-voice-preview.ts
index e2f3702..45c7119 100644
--- a/frontend/src/hooks/use-voice-preview.ts
+++ b/frontend/src/hooks/use-voice-preview.ts
@@ -71,6 +71,23 @@ export type SessionUpdateResult = {
export type ClientToolHandler = (
argumentsValue: Record,
) => unknown | Promise;
+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 = {
resolve: (result: UserInputResult) => void;
@@ -276,6 +293,7 @@ export function useVoicePreview(
const [audioOutputs, setAudioOutputs] = useState([]);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
+ const [clientTools, setClientTools] = useState([]);
const transportRef = useRef(null);
const cleanupPromiseRef = useRef>(Promise.resolve());
@@ -290,7 +308,9 @@ export function useVoicePreview(
const pendingSessionUpdatesRef = useRef(
new Map(),
);
- const clientToolHandlersRef = useRef(new Map());
+ const clientToolHandlersRef = useRef(
+ new Map(),
+ );
const endedByServerRef = useRef(false);
const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef("");
@@ -396,10 +416,10 @@ export function useVoicePreview(
const waitForResponse = msg.wait_for_response !== false;
if (!toolCallId || !functionName) return;
- const handler = clientToolHandlersRef.current.get(functionName);
+ const registeredTool = clientToolHandlersRef.current.get(functionName);
const transport = transportRef.current;
if (!transport || transport.state !== "connected") return;
- if (!handler) {
+ if (!registeredTool) {
if (waitForResponse) {
transport.sendAppMessage({
type: "client-tool-result",
@@ -418,7 +438,7 @@ export function useVoicePreview(
!Array.isArray(msg.arguments)
? (msg.arguments as Record)
: {};
- const data = await handler(argumentsValue);
+ const data = await registeredTool.handler(argumentsValue);
if (transportRef.current !== transport) return;
if (!waitForResponse) return;
transport.sendAppMessage({
@@ -889,11 +909,27 @@ export function useVoicePreview(
);
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 () => {
- if (clientToolHandlersRef.current.get(functionName) === handler) {
- clientToolHandlersRef.current.delete(functionName);
+ if (
+ 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,
messages,
sessionVariables,
+ clientTools,
callEnded,
networkQuality,
audioInputs,