574 lines
16 KiB
TypeScript
574 lines
16 KiB
TypeScript
/**
|
||
* 后端 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<T>(path: string, init?: RequestInit): Promise<T> {
|
||
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<AdminUser>("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
logout: () =>
|
||
request<{ ok: boolean }>("/api/auth/logout", {
|
||
method: "POST",
|
||
}),
|
||
me: () => request<AdminUser>("/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<string, unknown>;
|
||
secrets: Record<string, unknown>;
|
||
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<InterfaceDefinition[]>(
|
||
`/api/interface-definitions${capability ? `?capability=${capability}` : ""}`,
|
||
),
|
||
};
|
||
|
||
export const modelResourcesApi = {
|
||
list: () => request<ModelResource[]>("/api/model-resources"),
|
||
create: (body: ModelResourceUpsert) =>
|
||
request<ModelResource>("/api/model-resources", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
update: (id: string, body: ModelResourceUpsert) =>
|
||
request<ModelResource>(`/api/model-resources/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
test: (body: ModelResourceUpsert, id?: string) =>
|
||
request<ModelResourceTestResult>(
|
||
id ? `/api/model-resources/${id}/test` : "/api/model-resources/test",
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
},
|
||
),
|
||
duplicate: (id: string) =>
|
||
request<ModelResource>(`/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<Record<ModelType, string>>;
|
||
knowledgeBaseId: string | null;
|
||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||
toolIds: string[];
|
||
prompt: string;
|
||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||
apiUrl: string;
|
||
apiKey: string;
|
||
appId: string;
|
||
graph: Record<string, unknown>;
|
||
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<Assistant, "id" | "updatedAt">;
|
||
|
||
export const assistantsApi = {
|
||
list: () => request<Assistant[]>("/api/assistants"),
|
||
get: (id: string) => request<Assistant>(`/api/assistants/${id}`),
|
||
create: (body: AssistantUpsert) =>
|
||
request<Assistant>("/api/assistants", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
update: (id: string, body: AssistantUpsert) =>
|
||
request<Assistant>(`/api/assistants/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
||
duplicate: (id: string) =>
|
||
request<Assistant>(`/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<ConversationList>(`/api/conversations?${query}`);
|
||
},
|
||
get: (id: string) =>
|
||
request<ConversationDetail>(`/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<string, string>;
|
||
parameters: ToolParameter[];
|
||
body: Record<string, unknown>;
|
||
dynamicVariableAssignments: Record<string, string>;
|
||
};
|
||
};
|
||
|
||
export type McpToolDefinition = {
|
||
schemaVersion: number;
|
||
type: "mcp";
|
||
config: {
|
||
remoteToolName: string;
|
||
inputSchema: Record<string, unknown>;
|
||
schemaHash: string;
|
||
dynamicVariableAssignments: Record<string, string>;
|
||
};
|
||
};
|
||
|
||
export type Tool = {
|
||
id: string;
|
||
name: string;
|
||
functionName: string;
|
||
type: "end_call" | "http" | "mcp";
|
||
description: string;
|
||
definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition;
|
||
secrets: Record<string, unknown>;
|
||
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<Tool[]>("/api/tools"),
|
||
create: (body: ToolUpsert) =>
|
||
request<Tool>("/api/tools", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
update: (id: string, body: ToolUpsert) =>
|
||
request<Tool>(`/api/tools/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
remove: (id: string) =>
|
||
request<{ ok: boolean }>(`/api/tools/${id}`, { method: "DELETE" }),
|
||
duplicate: (id: string) =>
|
||
request<Tool>(`/api/tools/${id}/duplicate`, { method: "POST" }),
|
||
};
|
||
|
||
export type McpServer = {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
transport: "streamable_http";
|
||
url: string;
|
||
timeoutSeconds: number;
|
||
headers: Record<string, string>;
|
||
secretHeaders: Record<string, string>;
|
||
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<McpServer[]>("/api/mcp-servers"),
|
||
create: (body: McpServerUpsert) =>
|
||
request<McpServer>("/api/mcp-servers", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
update: (id: string, body: McpServerUpsert) =>
|
||
request<McpServer>(`/api/mcp-servers/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
sync: (id: string) =>
|
||
request<McpSyncResult>(`/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<KnowledgeBase[]>("/api/knowledge-bases"),
|
||
get: (id: string) => request<KnowledgeBase>(`/api/knowledge-bases/${id}`),
|
||
create: (body: KnowledgeBaseUpsert) =>
|
||
request<KnowledgeBase>("/api/knowledge-bases", { method: "POST", body: JSON.stringify(body) }),
|
||
update: (id: string, body: KnowledgeBaseUpsert) =>
|
||
request<KnowledgeBase>(`/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<KnowledgeDocument[]>(`/api/knowledge-bases/${id}/documents`),
|
||
addText: (id: string, body: { name: string; content: string }) =>
|
||
request<KnowledgeDocument>(`/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<KnowledgeDocument>(`/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<KnowledgeDocument>(`/api/knowledge-bases/${id}/documents/${documentId}/retry`, {
|
||
method: "POST",
|
||
}),
|
||
chunks: (id: string, documentId: string) =>
|
||
request<KnowledgeChunk[]>(`/api/knowledge-bases/${id}/documents/${documentId}/chunks`),
|
||
search: (id: string, query: string, topK = 5) =>
|
||
request<KnowledgeSearchResult[]>(`/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<NodeTypesResponse>("/api/node-types"),
|
||
};
|
||
|
||
export type IceServerConfig = {
|
||
urls: string | string[];
|
||
username?: string;
|
||
credential?: string;
|
||
};
|
||
|
||
export const webrtcApi = {
|
||
iceServers: () =>
|
||
request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"),
|
||
};
|