) => {
+ 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 = () => {
)}
)}
+ {selectedAssistant.generatedOpenerEnabled !== true && (
+
+
+
+
+
+ {(selectedAssistant.manualOpenerToolCalls || []).length === 0 ? (
+
+ 未配置。可添加 text_msg_prompt / voice_msg_prompt 等工具作为开场动作。
+
+ ) : (
+
+ {(selectedAssistant.manualOpenerToolCalls || []).map((call, idx) => (
+
+
+
+
+
+
+
参数 JSON
+ {OPENER_TOOL_ARGUMENT_TEMPLATES[normalizeToolId(call.toolName || '')] && (
+
+ )}
+
+
+ ))}
+
+ )}
+ {openerToolOptions.length === 0 && (
+
+ 当前助手未启用任何工具,请先在“工具配置”里添加后再选择。
+
+ )}
+
+ 按列表顺序执行。参数必须是 JSON 对象;保存时会校验格式。text_msg_prompt / voice_msg_prompt / text_choice_prompt / voice_choice_prompt 支持一键填充模板。
+
+
+ )}
{selectedAssistant.generatedOpenerEnabled === true
? '通话接通后将根据提示词自动生成开场白。'
@@ -1682,7 +1920,7 @@ const TOOL_PARAMETER_HINTS: Record = {
},
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('');
const debugVolumePercentRef = useRef(50);
const clientToolEnabledMapRef = useRef>(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');
diff --git a/web/pages/ToolLibrary.tsx b/web/pages/ToolLibrary.tsx
index 975b3d2..5e8fdbf 100644
--- a/web/pages/ToolLibrary.tsx
+++ b/web/pages/ToolLibrary.tsx
@@ -733,7 +733,7 @@ export const ToolLibraryPage: React.FC = () => {
setToolId(e.target.value)}
- placeholder="例如: voice_message_prompt(留空自动生成)"
+ placeholder="例如: voice_msg_prompt(留空自动生成)"
disabled={Boolean(editingTool)}
/>
diff --git a/web/services/backendApi.ts b/web/services/backendApi.ts
index c310b8e..5dabf95 100644
--- a/web/services/backendApi.ts
+++ b/web/services/backendApi.ts
@@ -3,6 +3,44 @@ import { apiRequest, getApiBaseUrl } from './apiClient';
type AnyRecord = Record;
const DEFAULT_LIST_LIMIT = 1000;
+const TOOL_ID_ALIASES: Record = {
+ 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();
+ 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): Promise): Promise): 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): 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 => {
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();
+ 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): Promise => {
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): Promise
wait_for_response: data.waitForResponse,
enabled: data.enabled,
};
- const response = await apiRequest(`/tools/resources/${id}`, { method: 'PUT', body: payload });
+ const response = await apiRequest(`/tools/resources/${normalizeToolId(id)}`, { method: 'PUT', body: payload });
return mapTool(response);
};
export const deleteTool = async (id: string): Promise => {
- await apiRequest(`/tools/resources/${id}`, { method: 'DELETE' });
+ await apiRequest(`/tools/resources/${normalizeToolId(id)}`, { method: 'DELETE' });
};
export const fetchWorkflows = async (): Promise => {
diff --git a/web/types.ts b/web/types.ts
index ab45028..aa5a89d 100644
--- a/web/types.ts
+++ b/web/types.ts
@@ -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;
+}
+
export interface Voice {
id: string;
name: string;