Files
AI-VideoAssistant/web/services/backendApi.ts

865 lines
33 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 OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL = 'https://api.siliconflow.cn/v1';
const DASHSCOPE_DEFAULT_ASR_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime';
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): any[] => {
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 any[];
};
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'], [])),
asrInterimEnabled: Boolean(readField(raw, ['asrInterimEnabled', 'asr_interim_enabled'], false)),
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: (() => {
const vendor = String(readField(raw, ['vendor'], '')).trim().toLowerCase();
if (vendor === 'dashscope') {
return 'DashScope';
}
if (vendor === 'siliconflow' || vendor === 'openai compatible' || vendor === 'openai-compatible' || vendor === '硅基流动') {
return 'OpenAI Compatible';
}
return String(readField(raw, ['vendor'], 'OpenAI Compatible')) || '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 || []),
asrInterimEnabled: data.asrInterimEnabled ?? false,
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),
asrInterimEnabled: data.asrInterimEnabled,
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 vendor = data.vendor || 'OpenAI Compatible';
const normalizedVendor = String(vendor).trim().toLowerCase();
const defaultBaseUrl = normalizedVendor === 'dashscope'
? DASHSCOPE_DEFAULT_ASR_BASE_URL
: OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL;
const payload = {
name: data.name || 'New ASR Model',
vendor,
language: data.language || 'zh',
base_url: data.baseUrl || defaultBaseUrl,
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,
};
};