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');

View File

@@ -733,7 +733,7 @@ export const ToolLibraryPage: React.FC = () => {
<Input
value={toolId}
onChange={(e) => setToolId(e.target.value)}
placeholder="例如: voice_message_prompt留空自动生成"
placeholder="例如: voice_msg_prompt留空自动生成"
disabled={Boolean(editingTool)}
/>
<p className="text-[11px] text-muted-foreground">

View File

@@ -3,6 +3,44 @@ import { apiRequest, getApiBaseUrl } from './apiClient';
type AnyRecord = Record<string, any>;
const DEFAULT_LIST_LIMIT = 1000;
const TOOL_ID_ALIASES: Record<string, string> = {
voice_message_prompt: 'voice_msg_prompt',
};
const normalizeToolId = (value: unknown): string => {
const raw = String(value || '').trim();
if (!raw) return '';
return TOOL_ID_ALIASES[raw] || raw;
};
const normalizeToolIdList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const result: string[] = [];
const seen = new Set<string>();
for (const item of value) {
const id = normalizeToolId(item);
if (!id || seen.has(id)) continue;
seen.add(id);
result.push(id);
}
return result;
};
const normalizeManualOpenerToolCalls = (value: unknown): AnyRecord[] => {
if (!Array.isArray(value)) return [];
return value
.filter((item) => item && typeof item === 'object')
.map((item) => {
const typed = item as AnyRecord;
const toolName = normalizeToolId(typed.toolName || typed.tool_name || typed.name || '');
if (!toolName) return null;
return {
...typed,
toolName,
};
})
.filter(Boolean) as AnyRecord[];
};
const withLimit = (path: string, limit: number = DEFAULT_LIST_LIMIT): string =>
`${path}${path.includes('?') ? '&' : '?'}limit=${limit}`;
@@ -35,6 +73,7 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
callCount: Number(readField(raw, ['callCount', 'call_count'], 0)),
firstTurnMode: readField(raw, ['firstTurnMode', 'first_turn_mode'], 'bot_first') as 'bot_first' | 'user_first',
opener: readField(raw, ['opener'], ''),
manualOpenerToolCalls: normalizeManualOpenerToolCalls(readField(raw, ['manualOpenerToolCalls', 'manual_opener_tool_calls'], [])),
generatedOpenerEnabled: Boolean(readField(raw, ['generatedOpenerEnabled', 'generated_opener_enabled'], false)),
openerAudioEnabled: Boolean(readField(raw, ['openerAudioEnabled', 'opener_audio_enabled'], false)),
openerAudioReady: Boolean(readField(raw, ['openerAudioReady', 'opener_audio_ready'], false)),
@@ -47,7 +86,7 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
voice: readField(raw, ['voice'], ''),
speed: Number(readField(raw, ['speed'], 1)),
hotwords: readField(raw, ['hotwords'], []),
tools: readField(raw, ['tools'], []),
tools: normalizeToolIdList(readField(raw, ['tools'], [])),
botCannotBeInterrupted: Boolean(readField(raw, ['botCannotBeInterrupted', 'bot_cannot_be_interrupted'], false)),
interruptionSensitivity: Number(readField(raw, ['interruptionSensitivity', 'interruption_sensitivity'], 500)),
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
@@ -114,7 +153,7 @@ const mapLLMModel = (raw: AnyRecord): LLMModel => ({
});
const mapTool = (raw: AnyRecord): Tool => ({
id: String(readField(raw, ['id'], '')),
id: normalizeToolId(readField(raw, ['id'], '')),
name: readField(raw, ['name'], ''),
description: readField(raw, ['description'], ''),
category: readField(raw, ['category'], 'system') as 'system' | 'query',
@@ -234,6 +273,7 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
name: data.name || 'New Assistant',
firstTurnMode: data.firstTurnMode || 'bot_first',
opener: data.opener || '',
manualOpenerToolCalls: normalizeManualOpenerToolCalls(data.manualOpenerToolCalls || []),
generatedOpenerEnabled: data.generatedOpenerEnabled ?? false,
openerAudioEnabled: data.openerAudioEnabled ?? false,
prompt: data.prompt || '',
@@ -243,7 +283,7 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
voice: data.voice || '',
speed: data.speed ?? 1,
hotwords: data.hotwords || [],
tools: data.tools || [],
tools: normalizeToolIdList(data.tools || []),
botCannotBeInterrupted: data.botCannotBeInterrupted ?? false,
interruptionSensitivity: data.interruptionSensitivity ?? 500,
configMode: data.configMode || 'platform',
@@ -263,6 +303,9 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
name: data.name,
firstTurnMode: data.firstTurnMode,
opener: data.opener,
manualOpenerToolCalls: data.manualOpenerToolCalls === undefined
? undefined
: normalizeManualOpenerToolCalls(data.manualOpenerToolCalls),
generatedOpenerEnabled: data.generatedOpenerEnabled,
openerAudioEnabled: data.openerAudioEnabled,
prompt: data.prompt,
@@ -272,7 +315,7 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
voice: data.voice,
speed: data.speed,
hotwords: data.hotwords,
tools: data.tools,
tools: data.tools === undefined ? undefined : normalizeToolIdList(data.tools),
botCannotBeInterrupted: data.botCannotBeInterrupted,
interruptionSensitivity: data.interruptionSensitivity,
configMode: data.configMode,
@@ -556,12 +599,20 @@ export const previewLLMModel = async (
export const fetchTools = async (): Promise<Tool[]> => {
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/tools/resources'));
const list = Array.isArray(response) ? response : (response.list || []);
return list.map((item) => mapTool(item));
const deduped = new Map<string, Tool>();
for (const item of list) {
const mapped = mapTool(item);
if (!mapped.id) continue;
if (!deduped.has(mapped.id)) {
deduped.set(mapped.id, mapped);
}
}
return Array.from(deduped.values());
};
export const createTool = async (data: Partial<Tool>): Promise<Tool> => {
const payload = {
id: data.id || undefined,
id: normalizeToolId(data.id || undefined) || undefined,
name: data.name || 'New Tool',
description: data.description || '',
category: data.category || 'system',
@@ -594,12 +645,12 @@ export const updateTool = async (id: string, data: Partial<Tool>): Promise<Tool>
wait_for_response: data.waitForResponse,
enabled: data.enabled,
};
const response = await apiRequest<AnyRecord>(`/tools/resources/${id}`, { method: 'PUT', body: payload });
const response = await apiRequest<AnyRecord>(`/tools/resources/${normalizeToolId(id)}`, { method: 'PUT', body: payload });
return mapTool(response);
};
export const deleteTool = async (id: string): Promise<void> => {
await apiRequest(`/tools/resources/${id}`, { method: 'DELETE' });
await apiRequest(`/tools/resources/${normalizeToolId(id)}`, { method: 'DELETE' });
};
export const fetchWorkflows = async (): Promise<Workflow[]> => {

View File

@@ -5,6 +5,7 @@ export interface Assistant {
callCount: number;
firstTurnMode?: 'bot_first' | 'user_first';
opener: string;
manualOpenerToolCalls?: AssistantOpenerToolCall[];
generatedOpenerEnabled?: boolean;
openerAudioEnabled?: boolean;
openerAudioReady?: boolean;
@@ -29,6 +30,11 @@ export interface Assistant {
rerankModelId?: string;
}
export interface AssistantOpenerToolCall {
toolName: string;
arguments?: string | Record<string, any>;
}
export interface Voice {
id: string;
name: string;