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,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[]> => {