- 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.
846 lines
32 KiB
TypeScript
846 lines
32 KiB
TypeScript
import { ASRModel, Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, LLMModel, Tool, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
|
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}`;
|
|
|
|
const readField = <T>(obj: AnyRecord, keys: string[], fallback: T): T => {
|
|
for (const key of keys) {
|
|
if (obj[key] !== undefined && obj[key] !== null) {
|
|
return obj[key] as T;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const normalizeDateLabel = (value: string): string => {
|
|
if (!value) return '';
|
|
return value.includes('T') ? value.replace('T', ' ').slice(0, 16) : value.slice(0, 16);
|
|
};
|
|
|
|
const formatDuration = (seconds?: number | null): string => {
|
|
if (!seconds || seconds <= 0) return '0s';
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
if (m === 0) return `${s}s`;
|
|
return `${m}m ${s}s`;
|
|
};
|
|
|
|
const mapAssistant = (raw: AnyRecord): Assistant => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
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)),
|
|
openerAudioDurationMs: Number(readField(raw, ['openerAudioDurationMs', 'opener_audio_duration_ms'], 0)),
|
|
openerAudioUpdatedAt: readField(raw, ['openerAudioUpdatedAt', 'opener_audio_updated_at'], ''),
|
|
prompt: readField(raw, ['prompt'], ''),
|
|
knowledgeBaseId: readField(raw, ['knowledgeBaseId', 'knowledge_base_id'], ''),
|
|
language: readField(raw, ['language'], 'zh') as 'zh' | 'en',
|
|
voiceOutputEnabled: Boolean(readField(raw, ['voiceOutputEnabled', 'voice_output_enabled'], true)),
|
|
voice: readField(raw, ['voice'], ''),
|
|
speed: Number(readField(raw, ['speed'], 1)),
|
|
hotwords: readField(raw, ['hotwords'], []),
|
|
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',
|
|
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
|
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
|
llmModelId: readField(raw, ['llmModelId', 'llm_model_id'], ''),
|
|
asrModelId: readField(raw, ['asrModelId', 'asr_model_id'], ''),
|
|
embeddingModelId: readField(raw, ['embeddingModelId', 'embedding_model_id'], ''),
|
|
rerankModelId: readField(raw, ['rerankModelId', 'rerank_model_id'], ''),
|
|
});
|
|
|
|
const mapVoice = (raw: AnyRecord): Voice => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
vendor: ((): string => {
|
|
const vendor = String(readField(raw, ['vendor'], '')).trim().toLowerCase();
|
|
if (vendor === 'dashscope') {
|
|
return 'DashScope';
|
|
}
|
|
if (vendor === 'siliconflow' || vendor === '硅基流动' || vendor === 'openai-compatible') {
|
|
return 'OpenAI Compatible';
|
|
}
|
|
return String(readField(raw, ['vendor'], 'OpenAI Compatible')) || 'OpenAI Compatible';
|
|
})(),
|
|
gender: readField(raw, ['gender'], ''),
|
|
language: readField(raw, ['language'], ''),
|
|
description: readField(raw, ['description'], ''),
|
|
model: readField(raw, ['model'], ''),
|
|
voiceKey: readField(raw, ['voiceKey', 'voice_key'], ''),
|
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
|
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
|
|
speed: Number(readField(raw, ['speed'], 1)),
|
|
gain: Number(readField(raw, ['gain'], 0)),
|
|
pitch: Number(readField(raw, ['pitch'], 0)),
|
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
|
isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
|
});
|
|
|
|
const mapASRModel = (raw: AnyRecord): ASRModel => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
vendor: readField(raw, ['vendor'], 'OpenAI Compatible'),
|
|
language: readField(raw, ['language'], 'zh'),
|
|
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
|
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
|
modelName: readField(raw, ['modelName', 'model_name'], ''),
|
|
hotwords: readField(raw, ['hotwords'], []),
|
|
enablePunctuation: Boolean(readField(raw, ['enablePunctuation', 'enable_punctuation'], true)),
|
|
enableNormalization: Boolean(readField(raw, ['enableNormalization', 'enable_normalization'], true)),
|
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
|
});
|
|
|
|
const mapLLMModel = (raw: AnyRecord): LLMModel => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
vendor: readField(raw, ['vendor'], 'OpenAI Compatible'),
|
|
type: readField(raw, ['type'], 'text'),
|
|
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
|
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
|
modelName: readField(raw, ['modelName', 'model_name'], ''),
|
|
temperature: Number(readField(raw, ['temperature'], 0.7)),
|
|
contextLength: Number(readField(raw, ['contextLength', 'context_length'], 0)),
|
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
|
});
|
|
|
|
const mapTool = (raw: AnyRecord): Tool => ({
|
|
id: normalizeToolId(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
description: readField(raw, ['description'], ''),
|
|
category: readField(raw, ['category'], 'system') as 'system' | 'query',
|
|
icon: readField(raw, ['icon'], 'Wrench'),
|
|
httpMethod: readField(raw, ['httpMethod', 'http_method'], 'GET') as Tool['httpMethod'],
|
|
httpUrl: readField(raw, ['httpUrl', 'http_url'], ''),
|
|
httpHeaders: readField(raw, ['httpHeaders', 'http_headers'], {}),
|
|
httpTimeoutMs: Number(readField(raw, ['httpTimeoutMs', 'http_timeout_ms'], 10000)),
|
|
parameterSchema: readField(raw, ['parameterSchema', 'parameter_schema'], {}),
|
|
parameterDefaults: readField(raw, ['parameterDefaults', 'parameter_defaults'], {}),
|
|
waitForResponse: Boolean(readField(raw, ['waitForResponse', 'wait_for_response'], false)),
|
|
isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
|
isCustom: !Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
|
});
|
|
|
|
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
|
id: readField(raw, ['id'], ''),
|
|
name: readField(raw, ['name'], String(readField(raw, ['id'], ''))),
|
|
type: readField(raw, ['type'], 'assistant') as WorkflowNode['type'],
|
|
isStart: readField(raw, ['isStart', 'is_start'], undefined),
|
|
metadata: (() => {
|
|
const metadata = readField(raw, ['metadata'], null);
|
|
if (metadata && typeof metadata === 'object') return metadata;
|
|
const position = readField(raw, ['position'], null);
|
|
if (position && typeof position === 'object') return { position };
|
|
return { position: { x: 200, y: 200 } };
|
|
})(),
|
|
assistantId: readField(raw, ['assistantId', 'assistant_id'], undefined),
|
|
assistant: readField(raw, ['assistant'], undefined),
|
|
prompt: readField(raw, ['prompt'], ''),
|
|
messagePlan: readField(raw, ['messagePlan', 'message_plan'], undefined),
|
|
variableExtractionPlan: readField(raw, ['variableExtractionPlan', 'variable_extraction_plan'], undefined),
|
|
tool: readField(raw, ['tool'], undefined),
|
|
globalNodePlan: readField(raw, ['globalNodePlan', 'global_node_plan'], undefined),
|
|
});
|
|
|
|
const mapWorkflowEdge = (raw: AnyRecord): WorkflowEdge => ({
|
|
id: readField(raw, ['id'], undefined),
|
|
fromNodeId: readField(raw, ['fromNodeId', 'from', 'from_', 'source'], ''),
|
|
toNodeId: readField(raw, ['toNodeId', 'to', 'target'], ''),
|
|
from: readField(raw, ['fromNodeId', 'from', 'from_', 'source'], ''),
|
|
to: readField(raw, ['toNodeId', 'to', 'target'], ''),
|
|
label: readField(raw, ['label'], undefined),
|
|
condition: readField(raw, ['condition'], undefined),
|
|
priority: Number(readField(raw, ['priority'], 100)),
|
|
});
|
|
|
|
const mapWorkflow = (raw: AnyRecord): Workflow => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
nodeCount: Number(readField(raw, ['nodeCount', 'node_count'], Array.isArray(raw.nodes) ? raw.nodes.length : 0)),
|
|
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
|
|
updatedAt: normalizeDateLabel(readField(raw, ['updatedAt', 'updated_at'], '')),
|
|
nodes: readField(raw, ['nodes'], []).map((node: AnyRecord) => mapWorkflowNode(node)),
|
|
edges: readField(raw, ['edges'], []).map((edge: AnyRecord) => mapWorkflowEdge(edge)),
|
|
globalPrompt: readField(raw, ['globalPrompt', 'global_prompt'], ''),
|
|
});
|
|
|
|
const mapKnowledgeDocument = (raw: AnyRecord): KnowledgeDocument => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
size: readField(raw, ['size'], ''),
|
|
uploadDate: normalizeDateLabel(readField(raw, ['uploadDate', 'upload_date', 'created_at'], '')),
|
|
status: readField(raw, ['status'], 'pending'),
|
|
chunkCount: Number(readField(raw, ['chunkCount', 'chunk_count'], 0)),
|
|
});
|
|
|
|
const mapKnowledgeBase = (raw: AnyRecord): KnowledgeBase => ({
|
|
id: String(readField(raw, ['id'], '')),
|
|
name: readField(raw, ['name'], ''),
|
|
creator: 'Admin',
|
|
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
|
|
description: readField(raw, ['description'], ''),
|
|
embeddingModel: readField(raw, ['embeddingModel', 'embedding_model'], ''),
|
|
chunkSize: Number(readField(raw, ['chunkSize', 'chunk_size'], 500)),
|
|
chunkOverlap: Number(readField(raw, ['chunkOverlap', 'chunk_overlap'], 50)),
|
|
status: readField(raw, ['status'], 'active'),
|
|
documents: readField(raw, ['documents'], []).map((doc: AnyRecord) => mapKnowledgeDocument(doc)),
|
|
});
|
|
|
|
const toHistoryRow = (raw: AnyRecord, assistantNameMap: Map<string, string>): CallLog => {
|
|
const assistantId = readField(raw, ['assistant_id', 'assistantId'], '');
|
|
const startTime = normalizeDateLabel(readField(raw, ['started_at', 'startTime'], ''));
|
|
const rawType = String(readField(raw, ['type'], 'text')).toLowerCase();
|
|
const type: CallLog['type'] = rawType === 'audio' || rawType === 'video' ? rawType : 'text';
|
|
return {
|
|
id: String(readField(raw, ['id'], '')),
|
|
source: readField(raw, ['source'], 'debug') as 'debug' | 'external',
|
|
status: readField(raw, ['status'], 'connected') as 'connected' | 'missed',
|
|
startTime,
|
|
duration: formatDuration(readField(raw, ['duration_seconds', 'durationSeconds'], 0)),
|
|
agentName: assistantNameMap.get(String(assistantId)) || String(assistantId || 'Unknown Assistant'),
|
|
type,
|
|
details: [],
|
|
};
|
|
};
|
|
|
|
const toHistoryDetails = (raw: AnyRecord): InteractionDetail[] => {
|
|
const transcripts = readField(raw, ['transcripts'], []);
|
|
return transcripts.map((t: AnyRecord) => ({
|
|
role: readField(t, ['speaker'], 'human') === 'human' ? 'user' : 'assistant',
|
|
content: readField(t, ['content'], ''),
|
|
timestamp: `${Math.floor(Number(readField(t, ['startMs', 'start_ms'], 0)) / 1000)}s`,
|
|
audioUrl: readField(t, ['audioUrl', 'audio_url'], undefined),
|
|
}));
|
|
};
|
|
|
|
export const fetchAssistants = async (): Promise<Assistant[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/assistants'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapAssistant(item));
|
|
};
|
|
|
|
export const createAssistant = async (data: Partial<Assistant>): Promise<Assistant> => {
|
|
const payload = {
|
|
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 || '',
|
|
knowledgeBaseId: data.knowledgeBaseId || '',
|
|
language: data.language || 'zh',
|
|
voiceOutputEnabled: data.voiceOutputEnabled ?? true,
|
|
voice: data.voice || '',
|
|
speed: data.speed ?? 1,
|
|
hotwords: data.hotwords || [],
|
|
tools: normalizeToolIdList(data.tools || []),
|
|
botCannotBeInterrupted: data.botCannotBeInterrupted ?? false,
|
|
interruptionSensitivity: data.interruptionSensitivity ?? 500,
|
|
configMode: data.configMode || 'platform',
|
|
apiUrl: data.apiUrl || '',
|
|
apiKey: data.apiKey || '',
|
|
llmModelId: data.llmModelId || '',
|
|
asrModelId: data.asrModelId || '',
|
|
embeddingModelId: data.embeddingModelId || '',
|
|
rerankModelId: data.rerankModelId || '',
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/assistants', { method: 'POST', body: payload });
|
|
return mapAssistant(response);
|
|
};
|
|
|
|
export const updateAssistant = async (id: string, data: Partial<Assistant>): Promise<Assistant> => {
|
|
const payload = {
|
|
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,
|
|
knowledgeBaseId: data.knowledgeBaseId,
|
|
language: data.language,
|
|
voiceOutputEnabled: data.voiceOutputEnabled,
|
|
voice: data.voice,
|
|
speed: data.speed,
|
|
hotwords: data.hotwords,
|
|
tools: data.tools === undefined ? undefined : normalizeToolIdList(data.tools),
|
|
botCannotBeInterrupted: data.botCannotBeInterrupted,
|
|
interruptionSensitivity: data.interruptionSensitivity,
|
|
configMode: data.configMode,
|
|
apiUrl: data.apiUrl,
|
|
apiKey: data.apiKey,
|
|
llmModelId: data.llmModelId,
|
|
asrModelId: data.asrModelId,
|
|
embeddingModelId: data.embeddingModelId,
|
|
rerankModelId: data.rerankModelId,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/assistants/${id}`, { method: 'PUT', body: payload });
|
|
return mapAssistant(response);
|
|
};
|
|
|
|
export const deleteAssistant = async (id: string): Promise<void> => {
|
|
await apiRequest(`/assistants/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export interface AssistantRuntimeConfigResponse {
|
|
assistantId: string;
|
|
configVersionId?: string;
|
|
assistant?: Record<string, any>;
|
|
sessionStartMetadata: Record<string, any>;
|
|
sources?: {
|
|
llmModelId?: string;
|
|
asrModelId?: string;
|
|
voiceId?: string;
|
|
};
|
|
warnings?: string[];
|
|
}
|
|
|
|
export interface AssistantOpenerAudioStatus {
|
|
enabled: boolean;
|
|
ready: boolean;
|
|
encoding: string;
|
|
sample_rate_hz: number;
|
|
channels: number;
|
|
duration_ms: number;
|
|
updated_at?: string | null;
|
|
text_hash?: string | null;
|
|
tts_fingerprint?: string | null;
|
|
}
|
|
|
|
export const fetchAssistantRuntimeConfig = async (assistantId: string): Promise<AssistantRuntimeConfigResponse> => {
|
|
return apiRequest<AssistantRuntimeConfigResponse>(`/assistants/${assistantId}/config`);
|
|
};
|
|
|
|
export const fetchAssistantOpenerAudioStatus = async (assistantId: string): Promise<AssistantOpenerAudioStatus> => {
|
|
return apiRequest<AssistantOpenerAudioStatus>(`/assistants/${assistantId}/opener-audio`);
|
|
};
|
|
|
|
export const generateAssistantOpenerAudio = async (
|
|
assistantId: string,
|
|
payload?: { text?: string }
|
|
): Promise<AssistantOpenerAudioStatus> => {
|
|
return apiRequest<AssistantOpenerAudioStatus>(`/assistants/${assistantId}/opener-audio/generate`, {
|
|
method: 'POST',
|
|
body: payload || {},
|
|
});
|
|
};
|
|
|
|
export const fetchAssistantOpenerAudioPcmBuffer = async (assistantId: string): Promise<ArrayBuffer> => {
|
|
const url = `${getApiBaseUrl()}/assistants/${assistantId}/opener-audio/pcm`;
|
|
const response = await fetch(url, { method: 'GET' });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch opener audio: ${response.status}`);
|
|
}
|
|
return response.arrayBuffer();
|
|
};
|
|
|
|
export const fetchVoices = async (): Promise<Voice[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/voices'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapVoice(item));
|
|
};
|
|
|
|
export const createVoice = async (data: Partial<Voice>): Promise<Voice> => {
|
|
const payload = {
|
|
name: data.name || 'New Voice',
|
|
vendor: data.vendor || 'OpenAI Compatible',
|
|
gender: data.gender || 'Female',
|
|
language: data.language || 'zh',
|
|
description: data.description || '',
|
|
model: data.model || undefined,
|
|
voice_key: data.voiceKey || undefined,
|
|
api_key: data.apiKey || undefined,
|
|
base_url: data.baseUrl || undefined,
|
|
speed: data.speed ?? 1,
|
|
gain: data.gain ?? 0,
|
|
pitch: data.pitch ?? 0,
|
|
enabled: data.enabled ?? true,
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/voices', { method: 'POST', body: payload });
|
|
return mapVoice(response);
|
|
};
|
|
|
|
export const updateVoice = async (id: string, data: Partial<Voice>): Promise<Voice> => {
|
|
const payload = {
|
|
name: data.name,
|
|
vendor: data.vendor,
|
|
gender: data.gender,
|
|
language: data.language,
|
|
description: data.description,
|
|
model: data.model,
|
|
voice_key: data.voiceKey,
|
|
api_key: data.apiKey,
|
|
base_url: data.baseUrl,
|
|
speed: data.speed,
|
|
gain: data.gain,
|
|
pitch: data.pitch,
|
|
enabled: data.enabled,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/voices/${id}`, { method: 'PUT', body: payload });
|
|
return mapVoice(response);
|
|
};
|
|
|
|
export const deleteVoice = async (id: string): Promise<void> => {
|
|
await apiRequest(`/voices/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const previewVoice = async (id: string, text: string, speed?: number, apiKey?: string): Promise<string> => {
|
|
const response = await apiRequest<{ success: boolean; audio_url?: string; error?: string }>(`/voices/${id}/preview`, {
|
|
method: 'POST',
|
|
body: { text, speed, api_key: apiKey },
|
|
});
|
|
if (!response.success || !response.audio_url) {
|
|
throw new Error(response.error || 'Preview failed');
|
|
}
|
|
return response.audio_url;
|
|
};
|
|
|
|
export const fetchASRModels = async (): Promise<ASRModel[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/asr'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapASRModel(item));
|
|
};
|
|
|
|
export const createASRModel = async (data: Partial<ASRModel>): Promise<ASRModel> => {
|
|
const payload = {
|
|
name: data.name || 'New ASR Model',
|
|
vendor: data.vendor || 'OpenAI Compatible',
|
|
language: data.language || 'zh',
|
|
base_url: data.baseUrl || 'https://api.siliconflow.cn/v1',
|
|
api_key: data.apiKey || '',
|
|
model_name: data.modelName || undefined,
|
|
hotwords: data.hotwords || [],
|
|
enable_punctuation: data.enablePunctuation ?? true,
|
|
enable_normalization: data.enableNormalization ?? true,
|
|
enabled: data.enabled ?? true,
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/asr', { method: 'POST', body: payload });
|
|
return mapASRModel(response);
|
|
};
|
|
|
|
export const updateASRModel = async (id: string, data: Partial<ASRModel>): Promise<ASRModel> => {
|
|
const payload = {
|
|
name: data.name,
|
|
vendor: data.vendor,
|
|
language: data.language,
|
|
base_url: data.baseUrl,
|
|
api_key: data.apiKey,
|
|
model_name: data.modelName,
|
|
hotwords: data.hotwords,
|
|
enable_punctuation: data.enablePunctuation,
|
|
enable_normalization: data.enableNormalization,
|
|
enabled: data.enabled,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/asr/${id}`, { method: 'PUT', body: payload });
|
|
return mapASRModel(response);
|
|
};
|
|
|
|
export const deleteASRModel = async (id: string): Promise<void> => {
|
|
await apiRequest(`/asr/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export type ASRPreviewResult = {
|
|
success: boolean;
|
|
transcript?: string;
|
|
language?: string;
|
|
confidence?: number;
|
|
latency_ms?: number;
|
|
message?: string;
|
|
error?: string;
|
|
};
|
|
|
|
export const previewASRModel = async (
|
|
id: string,
|
|
file: File,
|
|
options?: { language?: string; apiKey?: string }
|
|
): Promise<ASRPreviewResult> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
if (options?.language) {
|
|
formData.append('language', options.language);
|
|
}
|
|
if (options?.apiKey) {
|
|
formData.append('api_key', options.apiKey);
|
|
}
|
|
|
|
const url = `${getApiBaseUrl()}/asr/${id}/preview`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
let data: ASRPreviewResult | null = null;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const detail = (data as AnyRecord | null)?.error || (data as AnyRecord | null)?.detail || `Request failed: ${response.status}`;
|
|
throw new Error(typeof detail === 'string' ? detail : `Request failed: ${response.status}`);
|
|
}
|
|
|
|
return data || { success: false, error: 'Invalid preview response' };
|
|
};
|
|
|
|
export const fetchLLMModels = async (): Promise<LLMModel[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/llm'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapLLMModel(item));
|
|
};
|
|
|
|
export const createLLMModel = async (data: Partial<LLMModel>): Promise<LLMModel> => {
|
|
const payload = {
|
|
name: data.name || 'New LLM Model',
|
|
vendor: data.vendor || 'OpenAI Compatible',
|
|
type: data.type || 'text',
|
|
base_url: data.baseUrl || '',
|
|
api_key: data.apiKey || '',
|
|
model_name: data.modelName || undefined,
|
|
temperature: data.temperature,
|
|
context_length: data.contextLength,
|
|
enabled: data.enabled ?? true,
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/llm', { method: 'POST', body: payload });
|
|
return mapLLMModel(response);
|
|
};
|
|
|
|
export const updateLLMModel = async (id: string, data: Partial<LLMModel>): Promise<LLMModel> => {
|
|
const payload = {
|
|
name: data.name,
|
|
vendor: data.vendor,
|
|
type: data.type,
|
|
base_url: data.baseUrl,
|
|
api_key: data.apiKey,
|
|
model_name: data.modelName,
|
|
temperature: data.temperature,
|
|
context_length: data.contextLength,
|
|
enabled: data.enabled,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/llm/${id}`, { method: 'PUT', body: payload });
|
|
return mapLLMModel(response);
|
|
};
|
|
|
|
export const deleteLLMModel = async (id: string): Promise<void> => {
|
|
await apiRequest(`/llm/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export type LLMPreviewResult = {
|
|
success: boolean;
|
|
reply?: string;
|
|
usage?: Record<string, number>;
|
|
latency_ms?: number;
|
|
error?: string;
|
|
};
|
|
|
|
export const previewLLMModel = async (
|
|
id: string,
|
|
payload: { message: string; system_prompt?: string; max_tokens?: number; temperature?: number; api_key?: string }
|
|
): Promise<LLMPreviewResult> => {
|
|
return apiRequest<LLMPreviewResult>(`/llm/${id}/preview`, {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
};
|
|
|
|
export const fetchTools = async (): Promise<Tool[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/tools/resources'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
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: normalizeToolId(data.id || undefined) || undefined,
|
|
name: data.name || 'New Tool',
|
|
description: data.description || '',
|
|
category: data.category || 'system',
|
|
icon: data.icon || (data.category === 'query' ? 'Globe' : 'Terminal'),
|
|
http_method: data.httpMethod || 'GET',
|
|
http_url: data.httpUrl || null,
|
|
http_headers: data.httpHeaders || {},
|
|
http_timeout_ms: data.httpTimeoutMs ?? 10000,
|
|
parameter_schema: data.parameterSchema || {},
|
|
parameter_defaults: data.parameterDefaults || {},
|
|
wait_for_response: data.waitForResponse ?? false,
|
|
enabled: data.enabled ?? true,
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/tools/resources', { method: 'POST', body: payload });
|
|
return mapTool(response);
|
|
};
|
|
|
|
export const updateTool = async (id: string, data: Partial<Tool>): Promise<Tool> => {
|
|
const payload = {
|
|
name: data.name,
|
|
description: data.description,
|
|
category: data.category,
|
|
icon: data.icon,
|
|
http_method: data.httpMethod,
|
|
http_url: data.httpUrl,
|
|
http_headers: data.httpHeaders,
|
|
http_timeout_ms: data.httpTimeoutMs,
|
|
parameter_schema: data.parameterSchema,
|
|
parameter_defaults: data.parameterDefaults,
|
|
wait_for_response: data.waitForResponse,
|
|
enabled: data.enabled,
|
|
};
|
|
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/${normalizeToolId(id)}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/workflows'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapWorkflow(item));
|
|
};
|
|
|
|
export const fetchWorkflowById = async (id: string): Promise<Workflow> => {
|
|
const response = await apiRequest<AnyRecord>(`/workflows/${id}`);
|
|
return mapWorkflow(response);
|
|
};
|
|
|
|
export const createWorkflow = async (data: Partial<Workflow>): Promise<Workflow> => {
|
|
const payload = {
|
|
name: data.name || '新工作流',
|
|
nodeCount: data.nodeCount ?? data.nodes?.length ?? 0,
|
|
createdAt: data.createdAt || '',
|
|
updatedAt: data.updatedAt || '',
|
|
globalPrompt: data.globalPrompt || '',
|
|
nodes: data.nodes || [],
|
|
edges: data.edges || [],
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/workflows', { method: 'POST', body: payload });
|
|
return mapWorkflow(response);
|
|
};
|
|
|
|
export const updateWorkflow = async (id: string, data: Partial<Workflow>): Promise<Workflow> => {
|
|
const payload = {
|
|
name: data.name,
|
|
nodeCount: data.nodeCount ?? data.nodes?.length,
|
|
nodes: data.nodes,
|
|
edges: data.edges,
|
|
globalPrompt: data.globalPrompt,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/workflows/${id}`, { method: 'PUT', body: payload });
|
|
return mapWorkflow(response);
|
|
};
|
|
|
|
export const deleteWorkflow = async (id: string): Promise<void> => {
|
|
await apiRequest(`/workflows/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const fetchKnowledgeBases = async (): Promise<KnowledgeBase[]> => {
|
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/knowledge/bases'));
|
|
const list = Array.isArray(response) ? response : (response.list || []);
|
|
return list.map((item) => mapKnowledgeBase(item));
|
|
};
|
|
|
|
export const fetchKnowledgeBaseById = async (kbId: string): Promise<KnowledgeBase> => {
|
|
const response = await apiRequest<AnyRecord>(`/knowledge/bases/${kbId}`);
|
|
return mapKnowledgeBase(response);
|
|
};
|
|
|
|
export const createKnowledgeBase = async (data: {
|
|
name: string;
|
|
description?: string;
|
|
embeddingModel?: string;
|
|
chunkSize?: number;
|
|
chunkOverlap?: number;
|
|
}): Promise<KnowledgeBase> => {
|
|
const payload = {
|
|
name: data.name,
|
|
description: data.description || '',
|
|
embeddingModel: data.embeddingModel || 'text-embedding-3-small',
|
|
chunkSize: data.chunkSize ?? 500,
|
|
chunkOverlap: data.chunkOverlap ?? 50,
|
|
};
|
|
const response = await apiRequest<AnyRecord>('/knowledge/bases', { method: 'POST', body: payload });
|
|
return mapKnowledgeBase(response);
|
|
};
|
|
|
|
export const updateKnowledgeBase = async (kbId: string, data: Partial<KnowledgeBase>): Promise<KnowledgeBase> => {
|
|
const payload = {
|
|
name: data.name,
|
|
description: data.description,
|
|
embeddingModel: data.embeddingModel,
|
|
chunkSize: data.chunkSize,
|
|
chunkOverlap: data.chunkOverlap,
|
|
status: data.status,
|
|
};
|
|
const response = await apiRequest<AnyRecord>(`/knowledge/bases/${kbId}`, { method: 'PUT', body: payload });
|
|
return mapKnowledgeBase(response);
|
|
};
|
|
|
|
export const deleteKnowledgeBase = async (kbId: string): Promise<void> => {
|
|
await apiRequest(`/knowledge/bases/${kbId}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const uploadKnowledgeDocument = async (kbId: string, file: File): Promise<void> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', file.name);
|
|
formData.append('size', `${file.size} bytes`);
|
|
formData.append('file_type', file.type || 'application/octet-stream');
|
|
|
|
const url = `${getApiBaseUrl()}/knowledge/bases/${kbId}/documents`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = `Upload failed: ${response.status}`;
|
|
try {
|
|
const data = await response.json();
|
|
if (data?.detail) {
|
|
message = typeof data.detail === 'string' ? data.detail : message;
|
|
}
|
|
} catch {
|
|
// ignore parse error
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
|
|
export const deleteKnowledgeDocument = async (kbId: string, docId: string): Promise<void> => {
|
|
await apiRequest(`/knowledge/bases/${kbId}/documents/${docId}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const indexKnowledgeDocument = async (
|
|
kbId: string,
|
|
docId: string,
|
|
content: string,
|
|
): Promise<{ message: string; chunkCount: number }> => {
|
|
return apiRequest<{ message: string; chunkCount: number }>(
|
|
`/knowledge/bases/${kbId}/documents/${docId}/index`,
|
|
{
|
|
method: 'POST',
|
|
body: {
|
|
document_id: docId,
|
|
content,
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
export type KnowledgeSearchResultItem = {
|
|
content: string;
|
|
metadata?: {
|
|
document_id?: string;
|
|
chunk_index?: number;
|
|
kb_id?: string;
|
|
[key: string]: any;
|
|
};
|
|
distance?: number;
|
|
};
|
|
|
|
export type KnowledgeSearchResponse = {
|
|
query: string;
|
|
results: KnowledgeSearchResultItem[];
|
|
};
|
|
|
|
export const searchKnowledgeBase = async (
|
|
kbId: string,
|
|
query: string,
|
|
nResults: number = 5
|
|
): Promise<KnowledgeSearchResponse> => {
|
|
return apiRequest<KnowledgeSearchResponse>('/knowledge/search', {
|
|
method: 'POST',
|
|
body: {
|
|
kb_id: kbId,
|
|
query,
|
|
nResults,
|
|
},
|
|
});
|
|
};
|
|
|
|
export const fetchHistory = async (): Promise<CallLog[]> => {
|
|
const [historyResp, assistantsResp] = await Promise.all([
|
|
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/history')),
|
|
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>(withLimit('/assistants')),
|
|
]);
|
|
|
|
const assistantList = Array.isArray(assistantsResp) ? assistantsResp : (assistantsResp.list || []);
|
|
const assistantNameMap = new Map<string, string>(assistantList.map((a: AnyRecord) => [String(readField(a, ['id'], '')), readField(a, ['name'], '')]));
|
|
const historyList = Array.isArray(historyResp) ? historyResp : (historyResp.list || []);
|
|
return historyList.map((item) => toHistoryRow(item, assistantNameMap));
|
|
};
|
|
|
|
export const fetchHistoryDetail = async (id: string, base: CallLog): Promise<CallLog> => {
|
|
const response = await apiRequest<AnyRecord>(`/history/${id}`);
|
|
const details = toHistoryDetails(response);
|
|
const duration = formatDuration(readField(response, ['duration_seconds', 'durationSeconds'], 0));
|
|
const startTime = normalizeDateLabel(readField(response, ['started_at'], base.startTime));
|
|
return {
|
|
...base,
|
|
startTime,
|
|
duration,
|
|
details,
|
|
};
|
|
};
|