Add manual opener tool calls to Assistant model and API

- Introduced `manual_opener_tool_calls` field in the Assistant model to support custom tool calls.
- Updated AssistantBase and AssistantUpdate schemas to include the new field.
- Implemented normalization and migration logic for handling manual opener tool calls in the API.
- Enhanced runtime metadata to include manual opener tool calls in responses.
- Updated tests to validate the new functionality and ensure proper handling of tool calls.
- Refactored tool ID normalization to support legacy tool names for backward compatibility.
This commit is contained in:
Xin Wang
2026-03-02 12:34:42 +08:00
parent b5cdb76e52
commit 00b88c5afa
14 changed files with 806 additions and 74 deletions

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Plus, Search, Play, Square, Copy, Trash2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Database, Server, Zap, ExternalLink, Key, BrainCircuit, Ear, Book, Filter } from 'lucide-react';
import { Button, Input, Badge, Drawer, Dialog, Switch } from '../components/UI';
import { ASRModel, Assistant, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
import { ASRModel, Assistant, AssistantOpenerToolCall, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
import { createAssistant, deleteAssistant, fetchASRModels, fetchAssistantOpenerAudioPcmBuffer, fetchAssistants, fetchKnowledgeBases, fetchLLMModels, fetchTools, fetchVoices, generateAssistantOpenerAudio, previewVoice, updateAssistant as updateAssistantApi } from '../services/backendApi';
const isOpenAICompatibleVendor = (vendor?: string) => {
@@ -85,6 +85,80 @@ const renderToolIcon = (icon: string) => {
return map[icon] || <Wrench className={className} />;
};
const TOOL_ID_ALIASES: Record<string, string> = {
voice_message_prompt: 'voice_msg_prompt',
};
const normalizeToolId = (raw: unknown): string => {
const toolId = String(raw || '').trim();
if (!toolId) return '';
return TOOL_ID_ALIASES[toolId] || toolId;
};
const OPENER_TOOL_ARGUMENT_TEMPLATES: Record<string, Record<string, any>> = {
text_msg_prompt: {
msg: '您好,请先描述您要咨询的问题。',
},
voice_msg_prompt: {
msg: '您好,请先描述您要咨询的问题。',
},
text_choice_prompt: {
question: '请选择需要办理的业务',
options: [
{ id: 'billing', label: '账单咨询', value: 'billing' },
{ id: 'repair', label: '故障报修', value: 'repair' },
{ id: 'manual', label: '人工客服', value: 'manual' },
],
},
voice_choice_prompt: {
question: '请选择需要办理的业务',
options: [
{ id: 'billing', label: '账单咨询', value: 'billing' },
{ id: 'repair', label: '故障报修', value: 'repair' },
{ id: 'manual', label: '人工客服', value: 'manual' },
],
voice_text: '请从以下选项中选择:账单咨询、故障报修或人工客服。',
},
};
const normalizeManualOpenerToolCallsForRuntime = (
calls: AssistantOpenerToolCall[] | undefined,
options?: { strictJson?: boolean }
): { calls: Array<{ toolName: string; arguments: Record<string, any> }>; error?: string } => {
const strictJson = options?.strictJson === true;
const normalized: Array<{ toolName: string; arguments: Record<string, any> }> = [];
if (!Array.isArray(calls)) return { calls: normalized };
for (let i = 0; i < calls.length; i += 1) {
const item = calls[i];
if (!item || typeof item !== 'object') continue;
const toolName = normalizeToolId(item.toolName || '');
if (!toolName) continue;
const argsRaw = item.arguments;
let args: Record<string, any> = {};
if (argsRaw && typeof argsRaw === 'object' && !Array.isArray(argsRaw)) {
args = argsRaw as Record<string, any>;
} else if (typeof argsRaw === 'string' && argsRaw.trim()) {
try {
const parsed = JSON.parse(argsRaw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
args = parsed as Record<string, any>;
} else if (strictJson) {
return { calls: normalized, error: `Opener tool call #${i + 1} arguments must be a JSON object.` };
}
} catch {
if (strictJson) {
return { calls: normalized, error: `Opener tool call #${i + 1} has invalid JSON arguments.` };
}
}
}
normalized.push({ toolName, arguments: args });
}
return { calls: normalized.slice(0, 8) };
};
export const AssistantsPage: React.FC = () => {
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [voices, setVoices] = useState<Voice[]>([]);
@@ -173,6 +247,7 @@ export const AssistantsPage: React.FC = () => {
name: 'New Assistant',
firstTurnMode: 'bot_first',
opener: '',
manualOpenerToolCalls: [],
generatedOpenerEnabled: false,
openerAudioEnabled: false,
prompt: '',
@@ -201,9 +276,17 @@ export const AssistantsPage: React.FC = () => {
const handleSave = async () => {
if (!selectedAssistant) return;
const normalizedManualCalls = normalizeManualOpenerToolCallsForRuntime(selectedAssistant.manualOpenerToolCalls, { strictJson: true });
if (normalizedManualCalls.error) {
alert(normalizedManualCalls.error);
return;
}
setSaveLoading(true);
try {
const updated = await updateAssistantApi(selectedAssistant.id, selectedAssistant);
const updated = await updateAssistantApi(selectedAssistant.id, {
...selectedAssistant,
manualOpenerToolCalls: normalizedManualCalls.calls,
});
setAssistants((prev) => prev.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setPersistedAssistantSnapshotById((prev) => ({ ...prev, [updated.id]: serializeAssistant(updated) }));
} catch (error) {
@@ -436,17 +519,19 @@ export const AssistantsPage: React.FC = () => {
const toggleTool = (toolId: string) => {
if (!selectedAssistant) return;
const currentTools = selectedAssistant.tools || [];
const newTools = currentTools.includes(toolId)
? currentTools.filter(id => id !== toolId)
: [...currentTools, toolId];
const canonicalToolId = normalizeToolId(toolId);
const currentTools = (selectedAssistant.tools || []).map((id) => normalizeToolId(id));
const newTools = currentTools.includes(canonicalToolId)
? currentTools.filter(id => id !== canonicalToolId)
: [...currentTools, canonicalToolId];
updateAssistant('tools', newTools);
};
const removeImportedTool = (e: React.MouseEvent, tool: Tool) => {
e.stopPropagation();
if (!selectedAssistant) return;
updateAssistant('tools', (selectedAssistant.tools || []).filter((id) => id !== tool.id));
const canonicalToolId = normalizeToolId(tool.id);
updateAssistant('tools', (selectedAssistant.tools || []).filter((id) => normalizeToolId(id) !== canonicalToolId));
};
const addHotword = () => {
@@ -462,13 +547,76 @@ export const AssistantsPage: React.FC = () => {
}
};
const addManualOpenerToolCall = () => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (current.length >= 8) return;
const fallbackTool = normalizeToolId(
(selectedAssistant.tools || []).find((id) =>
tools.some((tool) => normalizeToolId(tool.id) === normalizeToolId(id) && tool.enabled !== false)
) || ''
);
updateAssistant('manualOpenerToolCalls', [
...current,
{
toolName: fallbackTool,
arguments: '{}',
},
]);
};
const updateManualOpenerToolCall = (index: number, patch: Partial<AssistantOpenerToolCall>) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (index < 0 || index >= current.length) return;
const next = [...current];
const normalizedPatch = { ...patch };
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'toolName')) {
normalizedPatch.toolName = normalizeToolId(normalizedPatch.toolName || '');
}
next[index] = { ...next[index], ...normalizedPatch };
updateAssistant('manualOpenerToolCalls', next);
};
const removeManualOpenerToolCall = (index: number) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
updateAssistant('manualOpenerToolCalls', current.filter((_, idx) => idx !== index));
};
const applyManualOpenerToolTemplate = (index: number) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (index < 0 || index >= current.length) return;
const toolName = normalizeToolId(current[index]?.toolName || '');
const template = OPENER_TOOL_ARGUMENT_TEMPLATES[toolName];
if (!template) return;
updateManualOpenerToolCall(index, {
arguments: JSON.stringify(template, null, 2),
});
};
const systemTools = tools.filter((t) => t.enabled !== false && t.category === 'system');
const queryTools = tools.filter((t) => t.enabled !== false && t.category === 'query');
const selectedToolIds = selectedAssistant?.tools || [];
const activeSystemTools = systemTools.filter((tool) => selectedToolIds.includes(tool.id));
const activeQueryTools = queryTools.filter((tool) => selectedToolIds.includes(tool.id));
const availableSystemTools = systemTools.filter((tool) => !selectedToolIds.includes(tool.id));
const availableQueryTools = queryTools.filter((tool) => !selectedToolIds.includes(tool.id));
const selectedToolIds = (selectedAssistant?.tools || []).map((id) => normalizeToolId(id));
const activeSystemTools = systemTools.filter((tool) => selectedToolIds.includes(normalizeToolId(tool.id)));
const activeQueryTools = queryTools.filter((tool) => selectedToolIds.includes(normalizeToolId(tool.id)));
const availableSystemTools = systemTools.filter((tool) => !selectedToolIds.includes(normalizeToolId(tool.id)));
const availableQueryTools = queryTools.filter((tool) => !selectedToolIds.includes(normalizeToolId(tool.id)));
const openerToolOptions = Array.from(
new Map(
tools
.filter(
(tool) =>
tool.enabled !== false &&
selectedToolIds.some((selectedId) => normalizeToolId(selectedId) === normalizeToolId(tool.id))
)
.map((tool) => {
const toolId = normalizeToolId(tool.id);
return [toolId, { id: toolId, label: `${tool.name} (${toolId})` }];
})
).values()
);
const isExternalConfig = selectedAssistant?.configMode === 'dify' || selectedAssistant?.configMode === 'fastgpt';
const isNoneConfig = selectedAssistant?.configMode === 'none' || !selectedAssistant?.configMode;
@@ -949,6 +1097,96 @@ export const AssistantsPage: React.FC = () => {
)}
</div>
)}
{selectedAssistant.generatedOpenerEnabled !== true && (
<div className="mt-3 p-3 rounded-lg border border-white/10 bg-white/[0.03] space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-white flex items-center">
<Wrench className="w-4 h-4 mr-2 text-primary" />
</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={addManualOpenerToolCall}
disabled={(selectedAssistant.manualOpenerToolCalls || []).length >= 8}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
{(selectedAssistant.manualOpenerToolCalls || []).length === 0 ? (
<div className="text-[11px] text-muted-foreground border border-dashed border-white/10 rounded-md px-2 py-2">
text_msg_prompt / voice_msg_prompt
</div>
) : (
<div className="space-y-2">
{(selectedAssistant.manualOpenerToolCalls || []).map((call, idx) => (
<div key={`manual-opener-tool-${idx}`} className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
<select
className="h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-foreground"
value={normalizeToolId(call.toolName || '')}
onChange={(e) => updateManualOpenerToolCall(idx, { toolName: e.target.value })}
>
<option value=""></option>
{normalizeToolId(call.toolName || '') && !openerToolOptions.some((tool) => tool.id === normalizeToolId(call.toolName || '')) && (
<option value={normalizeToolId(call.toolName || '')}>
{`${normalizeToolId(call.toolName || '')} (未启用/不存在)`}
</option>
)}
{openerToolOptions.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.label}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-red-300"
onClick={() => removeManualOpenerToolCall(idx)}
title="删除该工具调用"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground"> JSON</p>
{OPENER_TOOL_ARGUMENT_TEMPLATES[normalizeToolId(call.toolName || '')] && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => applyManualOpenerToolTemplate(idx)}
>
</Button>
)}
</div>
<textarea
value={typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments || {}, null, 2)}
onChange={(e) => updateManualOpenerToolCall(idx, { arguments: e.target.value })}
rows={3}
className="w-full rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-foreground"
placeholder='参数 JSON可选例如 {"msg":"您好,请先选择业务类型"}。可点“填充模板”自动生成。'
/>
</div>
))}
</div>
)}
{openerToolOptions.length === 0 && (
<p className="text-[11px] text-amber-300/90">
</p>
)}
<p className="text-[11px] text-muted-foreground">
JSON text_msg_prompt / voice_msg_prompt / text_choice_prompt / voice_choice_prompt
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
{selectedAssistant.generatedOpenerEnabled === true
? '通话接通后将根据提示词自动生成开场白。'
@@ -1682,7 +1920,7 @@ const TOOL_PARAMETER_HINTS: Record<string, any> = {
},
required: [],
},
voice_message_prompt: {
voice_msg_prompt: {
type: 'object',
properties: {
msg: { type: 'string', description: 'Message text to speak' },
@@ -1766,7 +2004,7 @@ const DEBUG_CLIENT_TOOLS = [
{ id: 'turn_off_camera', name: 'turn_off_camera', description: '关闭摄像头' },
{ id: 'increase_volume', name: 'increase_volume', description: '调高音量' },
{ id: 'decrease_volume', name: 'decrease_volume', description: '调低音量' },
{ id: 'voice_message_prompt', name: 'voice_message_prompt', description: '语音消息提示' },
{ id: 'voice_msg_prompt', name: 'voice_msg_prompt', description: '语音消息提示' },
{ id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' },
{ id: 'voice_choice_prompt', name: 'voice_choice_prompt', description: '语音选项提示(原子)' },
{ id: 'text_choice_prompt', name: 'text_choice_prompt', description: '文本选项提示(等待选择)' },
@@ -2208,11 +2446,13 @@ export const DebugDrawer: React.FC<{
const lastUserFinalRef = useRef<string>('');
const debugVolumePercentRef = useRef<number>(50);
const clientToolEnabledMapRef = useRef<Record<string, boolean>>(clientToolEnabledMap);
const isClientToolEnabled = (toolId: string) => clientToolEnabledMap[toolId] !== false;
const isClientToolEnabledLive = (toolId: string) => clientToolEnabledMapRef.current[toolId] !== false;
const isClientToolEnabled = (toolId: string) => clientToolEnabledMap[normalizeToolId(toolId)] !== false;
const isClientToolEnabledLive = (toolId: string) => clientToolEnabledMapRef.current[normalizeToolId(toolId)] !== false;
const selectedToolSchemas = useMemo(() => {
const ids = Array.from(new Set([...(assistant.tools || []), ...DEBUG_CLIENT_TOOLS.map((item) => item.id)]));
const byId = new Map(tools.map((t) => [t.id, t]));
const ids = Array.from(
new Set([...(assistant.tools || []).map((id) => normalizeToolId(id)), ...DEBUG_CLIENT_TOOLS.map((item) => item.id)])
);
const byId = new Map(tools.map((t) => [normalizeToolId(t.id), { ...t, id: normalizeToolId(t.id) }]));
return ids.map((id) => {
const item = byId.get(id);
const toolId = item?.id || id;
@@ -3006,6 +3246,7 @@ export const DebugDrawer: React.FC<{
'firstTurnMode',
'greeting',
'generatedOpenerEnabled',
'manualOpenerToolCalls',
'systemPrompt',
'output',
'bargeIn',
@@ -3146,6 +3387,11 @@ export const DebugDrawer: React.FC<{
const warnings: string[] = [];
const ttsEnabled = Boolean(textTtsEnabled);
const generatedOpenerEnabled = assistant.generatedOpenerEnabled === true;
const normalizedManualCalls = normalizeManualOpenerToolCallsForRuntime(assistant.manualOpenerToolCalls, { strictJson: true });
if (normalizedManualCalls.error) {
setDynamicVariablesError(normalizedManualCalls.error);
throw createDynamicVariablesError(normalizedManualCalls.error);
}
const knowledgeBaseId = String(assistant.knowledgeBaseId || '').trim();
const knowledge = knowledgeBaseId
? { enabled: true, kbId: knowledgeBaseId, nResults: 5 }
@@ -3162,6 +3408,7 @@ export const DebugDrawer: React.FC<{
firstTurnMode: assistant.firstTurnMode || 'bot_first',
greeting: generatedOpenerEnabled ? '' : (assistant.opener || ''),
generatedOpenerEnabled,
manualOpenerToolCalls: generatedOpenerEnabled ? [] : normalizedManualCalls.calls,
bargeIn: {
enabled: assistant.botCannotBeInterrupted !== true,
minDurationMs: Math.max(0, Number(assistant.interruptionSensitivity ?? 180)),
@@ -3363,7 +3610,7 @@ export const DebugDrawer: React.FC<{
if (type === 'assistant.tool_call') {
const toolCall = payload?.tool_call || {};
const toolCallId = String(payload?.tool_call_id || toolCall?.id || '').trim();
const toolName = String(toolCall?.function?.name || toolCall?.name || 'unknown_tool');
const toolName = normalizeToolId(toolCall?.function?.name || toolCall?.name || 'unknown_tool');
const toolDisplayName = String(payload?.tool_display_name || toolCall?.displayName || toolName);
const executor = String(toolCall?.executor || 'server').toLowerCase();
const rawArgs = String(toolCall?.function?.arguments || '');
@@ -3478,7 +3725,7 @@ export const DebugDrawer: React.FC<{
level: debugVolumePercentRef.current,
};
resultPayload.status = { code: 200, message: 'ok' };
} else if (toolName === 'voice_message_prompt') {
} else if (toolName === 'voice_msg_prompt' || toolName === 'voice_message_prompt') {
const msg = String(parsedArgs?.msg || '').trim();
if (!msg) {
resultPayload.output = { message: "Missing required argument 'msg'" };
@@ -3574,7 +3821,7 @@ export const DebugDrawer: React.FC<{
if (type === 'assistant.tool_result') {
const result = payload?.result || {};
const toolName = String(result?.name || 'unknown_tool');
const toolName = normalizeToolId(result?.name || 'unknown_tool');
const toolDisplayName = String(payload?.tool_display_name || toolName);
const statusCode = Number(result?.status?.code || 500);
const statusMessage = String(result?.status?.message || 'error');