Frontend start to use backend CRUD api
This commit is contained in:
58
web/services/apiClient.ts
Normal file
58
web/services/apiClient.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:8000/api';
|
||||
|
||||
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
const configured = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL;
|
||||
return trimTrailingSlash(configured);
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiRequest = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData?.detail) {
|
||||
message = typeof errorData.detail === 'string' ? errorData.detail : message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors.
|
||||
}
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
276
web/services/backendApi.ts
Normal file
276
web/services/backendApi.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||
import { apiRequest } from './apiClient';
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
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)),
|
||||
opener: readField(raw, ['opener'], ''),
|
||||
prompt: readField(raw, ['prompt'], ''),
|
||||
knowledgeBaseId: readField(raw, ['knowledgeBaseId', 'knowledge_base_id'], ''),
|
||||
language: readField(raw, ['language'], 'zh') as 'zh' | 'en',
|
||||
voice: readField(raw, ['voice'], ''),
|
||||
speed: Number(readField(raw, ['speed'], 1)),
|
||||
hotwords: readField(raw, ['hotwords'], []),
|
||||
tools: readField(raw, ['tools'], []),
|
||||
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'], ''),
|
||||
});
|
||||
|
||||
const mapVoice = (raw: AnyRecord): Voice => ({
|
||||
id: String(readField(raw, ['id'], '')),
|
||||
name: readField(raw, ['name'], ''),
|
||||
vendor: readField(raw, ['vendor'], ''),
|
||||
gender: readField(raw, ['gender'], ''),
|
||||
language: readField(raw, ['language'], ''),
|
||||
description: readField(raw, ['description'], ''),
|
||||
});
|
||||
|
||||
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
||||
name: readField(raw, ['name'], ''),
|
||||
type: readField(raw, ['type'], 'conversation') as 'conversation' | 'tool' | 'human' | 'end',
|
||||
isStart: readField(raw, ['isStart', 'is_start'], undefined),
|
||||
metadata: readField(raw, ['metadata'], { position: { x: 200, y: 200 } }),
|
||||
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 => ({
|
||||
from: readField(raw, ['from', 'from_'], ''),
|
||||
to: readField(raw, ['to'], ''),
|
||||
label: readField(raw, ['label'], undefined),
|
||||
});
|
||||
|
||||
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'], '')),
|
||||
});
|
||||
|
||||
const mapKnowledgeBase = (raw: AnyRecord): KnowledgeBase => ({
|
||||
id: String(readField(raw, ['id'], '')),
|
||||
name: readField(raw, ['name'], ''),
|
||||
creator: 'Admin',
|
||||
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
|
||||
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 type = readField(raw, ['type'], '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: type === 'audio' || type === 'video' ? type : 'text',
|
||||
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[]>('/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',
|
||||
opener: data.opener || '',
|
||||
prompt: data.prompt || '',
|
||||
knowledgeBaseId: data.knowledgeBaseId || '',
|
||||
language: data.language || 'zh',
|
||||
voice: data.voice || '',
|
||||
speed: data.speed ?? 1,
|
||||
hotwords: data.hotwords || [],
|
||||
tools: data.tools || [],
|
||||
interruptionSensitivity: data.interruptionSensitivity ?? 500,
|
||||
configMode: data.configMode || 'platform',
|
||||
apiUrl: data.apiUrl || '',
|
||||
apiKey: data.apiKey || '',
|
||||
};
|
||||
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,
|
||||
opener: data.opener,
|
||||
prompt: data.prompt,
|
||||
knowledgeBaseId: data.knowledgeBaseId,
|
||||
language: data.language,
|
||||
voice: data.voice,
|
||||
speed: data.speed,
|
||||
hotwords: data.hotwords,
|
||||
tools: data.tools,
|
||||
interruptionSensitivity: data.interruptionSensitivity,
|
||||
configMode: data.configMode,
|
||||
apiUrl: data.apiUrl,
|
||||
apiKey: data.apiKey,
|
||||
};
|
||||
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 const fetchVoices = async (): Promise<Voice[]> => {
|
||||
const response = await apiRequest<AnyRecord[]>('/voices');
|
||||
return response.map((item) => mapVoice(item));
|
||||
};
|
||||
|
||||
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
||||
const response = await apiRequest<AnyRecord[]>('/workflows');
|
||||
return response.map((item) => mapWorkflow(item));
|
||||
};
|
||||
|
||||
export const fetchWorkflowById = async (id: string): Promise<Workflow> => {
|
||||
const list = await fetchWorkflows();
|
||||
const workflow = list.find((item) => item.id === id);
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
return workflow;
|
||||
};
|
||||
|
||||
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[]>('/knowledge/bases');
|
||||
const list = Array.isArray(response) ? response : (response.list || []);
|
||||
return list.map((item) => mapKnowledgeBase(item));
|
||||
};
|
||||
|
||||
export const createKnowledgeBase = async (name: string): Promise<KnowledgeBase> => {
|
||||
const payload = { name, description: '', embeddingModel: 'text-embedding-3-small', chunkSize: 500, chunkOverlap: 50 };
|
||||
const response = await apiRequest<AnyRecord>('/knowledge/bases', { method: 'POST', 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 params = new URLSearchParams({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||
file_type: file.type || 'txt',
|
||||
});
|
||||
await apiRequest(`/knowledge/bases/${kbId}/documents?${params.toString()}`, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const deleteKnowledgeDocument = async (kbId: string, docId: string): Promise<void> => {
|
||||
await apiRequest(`/knowledge/bases/${kbId}/documents/${docId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const fetchHistory = async (): Promise<CallLog[]> => {
|
||||
const [historyResp, assistantsResp] = await Promise.all([
|
||||
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/history'),
|
||||
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user