/** * 后端 API 客户端。基址走 NEXT_PUBLIC_API_BASE,缺省指向本地后端 :8000。 * * JSON 契约与后端 schemas.py 对齐(camelCase),所以返回体可直接喂给页面 state。 * 注意:api_key 读取时后端永远打码,写回打码占位符表示"不改 key"(写时哨兵)。 */ import { loginPathWithReturnTo } from "@/lib/auth-redirect"; export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent"; function formatErrorDetail(detail: unknown): string | null { if (typeof detail === "string") return detail; if (Array.isArray(detail)) { const messages = detail .map((item) => { if (!item || typeof item !== "object") return null; const record = item as { loc?: unknown; msg?: unknown }; const message = typeof record.msg === "string" ? record.msg : null; if (!message) return null; const path = Array.isArray(record.loc) ? record.loc .filter((part) => part !== "body") .map(String) .join(".") : ""; return path ? `${path}:${message}` : message; }) .filter((message): message is string => Boolean(message)); return messages.length ? messages.join(";") : null; } if (detail && typeof detail === "object") { const record = detail as { message?: unknown; msg?: unknown }; if (typeof record.message === "string") return record.message; if (typeof record.msg === "string") return record.msg; } return null; } async function request(path: string, init?: RequestInit): Promise { const isFormData = init?.body instanceof FormData; const res = await fetch(`${API_BASE}${path}`, { headers: isFormData ? undefined : { "Content-Type": "application/json" }, credentials: "include", ...init, }); if ( res.status === 401 && typeof window !== "undefined" && !path.startsWith("/api/auth/") ) { window.location.href = loginPathWithReturnTo( window.location.pathname + window.location.search, ); } if (!res.ok) { let detail = `请求失败 (${res.status})`; try { const body = (await res.json()) as { detail?: unknown }; detail = formatErrorDetail(body?.detail) ?? detail; } catch { // 响应体不是 JSON,沿用默认错误信息 } throw new Error(detail); } // 204 / 空响应保护 const text = await res.text(); return (text ? JSON.parse(text) : undefined) as T; } export type AdminUser = { username: string; displayName: string; role: "super_admin"; }; export const authApi = { login: (body: { username: string; password: string }) => request("/api/auth/login", { method: "POST", body: JSON.stringify(body), }), logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST", }), me: () => request("/api/auth/me"), }; // ---------- 接口定义驱动的模型注册表 ---------- export type InterfaceField = { key: string; label: string; group: "values" | "secrets"; type: "text" | "url" | "password" | "number" | "boolean" | "select"; required: boolean; default?: unknown; options?: string[]; }; export type InterfaceDefinition = { interfaceType: string; name: string; capability: ModelType; fieldSchema: { fields: InterfaceField[] }; enabled: boolean; version: number; }; export type ModelResource = { id: string; name: string; capability: ModelType; interfaceType: string; values: Record; secrets: Record; supportImageInput: boolean; enabled: boolean; isDefault: boolean; updatedAt?: string | null; }; export type ModelResourceUpsert = Omit< ModelResource, "id" | "capability" | "updatedAt" >; export type ModelResourceTestResult = { ok: boolean; latencyMs: number | null; message: string; detail: string; }; export const interfaceDefinitionsApi = { list: (capability?: ModelType) => request( `/api/interface-definitions${capability ? `?capability=${capability}` : ""}`, ), }; export const modelResourcesApi = { list: () => request("/api/model-resources"), create: (body: ModelResourceUpsert) => request("/api/model-resources", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: ModelResourceUpsert) => request(`/api/model-resources/${id}`, { method: "PUT", body: JSON.stringify(body), }), test: (body: ModelResourceUpsert, id?: string) => request( id ? `/api/model-resources/${id}/test` : "/api/model-resources/test", { method: "POST", body: JSON.stringify(body), }, ), duplicate: (id: string) => request(`/api/model-resources/${id}/duplicate`, { method: "POST", }), remove: (id: string) => request<{ ok: boolean }>(`/api/model-resources/${id}`, { method: "DELETE", }), }; // ---------- 助手 ---------- export type AssistantType = | "prompt" | "workflow" | "dify" | "fastgpt" | "opencode"; export type RuntimeMode = "pipeline" | "realtime"; export type TurnConfig = { bargeIn: { strategy: "vad" | "transcription"; }; vad: { confidence: number; startSecs: number; stopSecs: number; minVolume: number; }; turnDetection: { strategy: "silence" | "smart_turn"; silenceTimeoutSecs: number; }; }; /** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */ export type Assistant = { id: string; name: string; type: AssistantType; runtimeMode: RuntimeMode; greeting: string; enableInterrupt: boolean; turnConfig: TurnConfig; visionEnabled: boolean; visionModelResourceId: string | null; modelResourceIds: Partial>; knowledgeBaseId: string | null; knowledgeRetrievalConfig: KnowledgeRetrievalConfig; toolIds: string[]; prompt: string; dynamicVariableDefinitions: Record; apiUrl: string; apiKey: string; appId: string; graph: Record; updatedAt?: string | null; }; export type DynamicVariableDefinition = { type: "string" | "number" | "boolean"; required: boolean; default: string | number | boolean | null; }; export type KnowledgeRetrievalConfig = { mode: "automatic" | "on_demand"; topN: number; scoreThreshold: number; }; export type AssistantUpsert = Omit; export const assistantsApi = { list: () => request("/api/assistants"), get: (id: string) => request(`/api/assistants/${id}`), create: (body: AssistantUpsert) => request("/api/assistants", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: AssistantUpsert) => request(`/api/assistants/${id}`, { method: "PUT", body: JSON.stringify(body), }), // 服务端整行复制(含真 key,密钥不经浏览器) duplicate: (id: string) => request(`/api/assistants/${id}/duplicate`, { method: "POST" }), remove: (id: string) => request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }), }; // ---------- 对话历史 ---------- export type Conversation = { id: string; assistantId: string | null; assistantName: string; channel: string; runtimeMode: string; status: string; messageCount: number; startedAt: string; endedAt: string | null; }; export type ConversationMessage = { id: string; sequence: number; role: "user" | "assistant"; contentType: string; content: string; occurredAt: string; extra: { interrupted?: boolean; turn_id?: string }; }; export type ConversationDetail = Conversation & { messages: ConversationMessage[]; }; export type ConversationList = { items: Conversation[]; total: number; page: number; pageSize: number; }; export const conversationsApi = { list: (params: { page?: number; pageSize?: number; search?: string; channel?: string; status?: string; sortOrder?: "newest" | "oldest"; } = {}) => { const query = new URLSearchParams({ page: String(params.page ?? 1), page_size: String(params.pageSize ?? 20), sort_order: params.sortOrder ?? "newest", }); if (params.search) query.set("search", params.search); if (params.channel) query.set("channel", params.channel); if (params.status) query.set("status", params.status); return request(`/api/conversations?${query}`); }, get: (id: string) => request(`/api/conversations/${id}`), remove: (id: string) => request<{ ok: boolean }>(`/api/conversations/${id}`, { method: "DELETE" }), }; // ---------- 工具 ---------- export type ToolStatus = "active" | "archived" | "draft"; export type ToolParameter = { name: string; type: "string" | "number" | "integer" | "boolean" | "object" | "array"; location: "path" | "query" | "body" | "header"; description: string; required: boolean; }; export type EndCallToolDefinition = { schemaVersion: number; type: "end_call"; config: { messageType: "none" | "custom"; customMessage: string; captureReason: boolean; }; }; export type HttpToolDefinition = { schemaVersion: number; type: "http"; config: { method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; url: string; timeoutSeconds: number; headers: Record; parameters: ToolParameter[]; body: Record; dynamicVariableAssignments: Record; }; }; export type McpToolDefinition = { schemaVersion: number; type: "mcp"; config: { remoteToolName: string; inputSchema: Record; schemaHash: string; dynamicVariableAssignments: Record; }; }; export type Tool = { id: string; name: string; functionName: string; type: "end_call" | "http" | "mcp"; description: string; definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition; secrets: Record; status: ToolStatus; mcpServerId?: string | null; remoteToolName?: string | null; updatedAt?: string | null; }; export type ToolUpsert = Omit< Tool, "id" | "type" | "remoteToolName" | "updatedAt" >; export const toolsApi = { list: () => request("/api/tools"), create: (body: ToolUpsert) => request("/api/tools", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: ToolUpsert) => request(`/api/tools/${id}`, { method: "PUT", body: JSON.stringify(body), }), remove: (id: string) => request<{ ok: boolean }>(`/api/tools/${id}`, { method: "DELETE" }), duplicate: (id: string) => request(`/api/tools/${id}/duplicate`, { method: "POST" }), }; export type McpServer = { id: string; name: string; description: string; transport: "streamable_http"; url: string; timeoutSeconds: number; headers: Record; secretHeaders: Record; status: ToolStatus; toolCount: number; lastSyncedAt?: string | null; updatedAt?: string | null; }; export type McpServerUpsert = Omit< McpServer, "id" | "toolCount" | "lastSyncedAt" | "updatedAt" >; export type McpSyncResult = { server: McpServer; created: number; updated: number; tools: Tool[]; }; export const mcpServersApi = { list: () => request("/api/mcp-servers"), create: (body: McpServerUpsert) => request("/api/mcp-servers", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: McpServerUpsert) => request(`/api/mcp-servers/${id}`, { method: "PUT", body: JSON.stringify(body), }), sync: (id: string) => request(`/api/mcp-servers/${id}/sync`, { method: "POST", }), remove: (id: string) => request<{ ok: boolean }>(`/api/mcp-servers/${id}`, { method: "DELETE", }), }; // ---------- 知识库 ---------- export type KnowledgeBase = { id: string; name: string; description: string; embeddingModelResourceId: string | null; status: string; updatedAt?: string | null; }; export type KnowledgeDocument = { id: string; knowledgeBaseId: string; name: string; sourceType: "file" | "text"; mimeType: string; sizeBytes: number; status: string; errorMessage: string; chunkCount: number; createdAt?: string | null; }; export type KnowledgeSearchResult = { content: string; document: string; score: number; }; export type KnowledgeChunk = { id: string; chunkIndex: number; content: string; }; export type KnowledgeBaseUpsert = Pick< KnowledgeBase, "name" | "description" | "embeddingModelResourceId" >; export const knowledgeBasesApi = { list: () => request("/api/knowledge-bases"), get: (id: string) => request(`/api/knowledge-bases/${id}`), create: (body: KnowledgeBaseUpsert) => request("/api/knowledge-bases", { method: "POST", body: JSON.stringify(body) }), update: (id: string, body: KnowledgeBaseUpsert) => request(`/api/knowledge-bases/${id}`, { method: "PUT", body: JSON.stringify(body) }), remove: (id: string) => request<{ ok: boolean }>(`/api/knowledge-bases/${id}`, { method: "DELETE" }), documents: (id: string) => request(`/api/knowledge-bases/${id}/documents`), addText: (id: string, body: { name: string; content: string }) => request(`/api/knowledge-bases/${id}/documents/text`, { method: "POST", body: JSON.stringify(body), }), addFile: (id: string, file: File) => { const body = new FormData(); body.append("file", file); return request(`/api/knowledge-bases/${id}/documents/file`, { method: "POST", body, }); }, removeDocument: (id: string, documentId: string) => request<{ ok: boolean }>(`/api/knowledge-bases/${id}/documents/${documentId}`, { method: "DELETE", }), retryDocument: (id: string, documentId: string) => request(`/api/knowledge-bases/${id}/documents/${documentId}/retry`, { method: "POST", }), chunks: (id: string, documentId: string) => request(`/api/knowledge-bases/${id}/documents/${documentId}/chunks`), search: (id: string, query: string, topK = 5) => request(`/api/knowledge-bases/${id}/search`, { method: "POST", body: JSON.stringify({ query, topK }), }), }; // ---------- 工作流节点类型目录 ---------- export type NodeFieldSpec = { key: string; label: string; type: "text" | "textarea" | "switch"; required?: boolean; default?: unknown; }; export type NodeConstraints = { minIncoming?: number; maxIncoming?: number; minOutgoing?: number; maxOutgoing?: number; minInstances?: number; maxInstances?: number; }; export type NodeSpecDto = { name: string; displayName: string; category: string; description: string; icon: string; accent: string; addable: boolean; constraints: NodeConstraints; fields: NodeFieldSpec[]; }; export type NodeTypesResponse = { specVersion: string; nodeTypes: NodeSpecDto[]; }; export const nodeTypesApi = { list: () => request("/api/node-types"), }; export type IceServerConfig = { urls: string | string[]; username?: string; credential?: string; }; export const webrtcApi = { iceServers: () => request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"), };