Files
ai-video-fullstack/frontend/src/lib/api.ts
2026-07-18 00:00:06 +08:00

574 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 后端 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"),
};