addaa1b34f
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
// Gateway HTTP/SSE 客户端:提交 DSL 任务、订阅 Token 流、登记偏好记忆。
|
||
import type { TaskDsl } from "./dsl";
|
||
|
||
// 开发期直连 Gateway;Wails 打包后可改为本地后端地址或经 Go 绑定。
|
||
export const GATEWAY: string =
|
||
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
||
|
||
export interface Identity {
|
||
userId: string;
|
||
sessionId: string;
|
||
}
|
||
|
||
export interface AuthUser {
|
||
id: string;
|
||
email: string;
|
||
name?: string;
|
||
}
|
||
|
||
// ---- JWT 令牌存储(localStorage)----
|
||
const TOKEN_KEY = "sdx_token";
|
||
let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
|
||
|
||
export function setToken(t: string): void {
|
||
authToken = t;
|
||
try {
|
||
localStorage.setItem(TOKEN_KEY, t);
|
||
} catch {
|
||
/* 隐私模式忽略 */
|
||
}
|
||
}
|
||
export function clearToken(): void {
|
||
authToken = "";
|
||
try {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
export function getToken(): string {
|
||
return authToken;
|
||
}
|
||
|
||
// bearer 把 JWT 放进请求头(无令牌则不带)。
|
||
function bearer(): Record<string, string> {
|
||
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
||
}
|
||
|
||
// guard401 在收到 401 时清理令牌并广播登出事件(App 监听后回到登录页)。
|
||
function guard401(res: Response): Response {
|
||
if (res.status === 401) {
|
||
clearToken();
|
||
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ---- 鉴权 API ----
|
||
export async function authRegister(email: string, password: string, name: string): Promise<AuthUser> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/auth/register`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ email, password, name }),
|
||
});
|
||
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
|
||
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `注册失败: ${res.status}`);
|
||
setToken(data.token);
|
||
return data.user;
|
||
}
|
||
|
||
export async function authLogin(email: string, password: string): Promise<AuthUser> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ email, password }),
|
||
});
|
||
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
|
||
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`);
|
||
setToken(data.token);
|
||
return data.user;
|
||
}
|
||
|
||
// authMe 用当前令牌取登录用户;无效/过期返回 null(用于应用启动校验)。
|
||
export async function authMe(): Promise<AuthUser | null> {
|
||
if (!authToken) return null;
|
||
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
|
||
if (!res.ok) {
|
||
clearToken();
|
||
return null;
|
||
}
|
||
const data = (await res.json()) as { user?: AuthUser };
|
||
return data.user ?? null;
|
||
}
|
||
|
||
export function logout(): void {
|
||
clearToken();
|
||
}
|
||
|
||
// submitTask: POST /api/v1/tasks,返回 task_id。
|
||
export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
||
const res = guard401(
|
||
await fetch(`${GATEWAY}/api/v1/tasks`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify(dsl),
|
||
}),
|
||
);
|
||
if (!res.ok) {
|
||
// 402=积分/预算不足等业务拒绝:透出后端的友好文案(如「租户积分余额不足,请充值后再试」)。
|
||
let msg = `submit failed: ${res.status}`;
|
||
try {
|
||
const d = (await res.json()) as { error?: string };
|
||
if (d.error) msg = d.error;
|
||
} catch {
|
||
/* 非 JSON 响应,保留状态码文案 */
|
||
}
|
||
throw new Error(msg);
|
||
}
|
||
const data = (await res.json()) as { task_id: string };
|
||
return data.task_id;
|
||
}
|
||
|
||
// ---- 租户上下文 + 积分余额(面向当前用户自己的租户;只读,不碰 admin 口径)----
|
||
export interface TenantCtx {
|
||
tenant: { id: string; name: string; plan: string; status: string } | null;
|
||
role: string;
|
||
credit_balance_micro: number;
|
||
credit_enforce: boolean; // 平台是否开了余额硬拦截(开了且余额≤0 会拒绝提交)
|
||
}
|
||
|
||
export async function tenantCurrent(): Promise<TenantCtx | null> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() }));
|
||
if (!res.ok) return null;
|
||
const d = (await res.json()) as Partial<TenantCtx>;
|
||
return {
|
||
tenant: d.tenant ?? null,
|
||
role: d.role ?? "",
|
||
credit_balance_micro: d.credit_balance_micro ?? 0,
|
||
credit_enforce: !!d.credit_enforce,
|
||
};
|
||
}
|
||
|
||
// ---- 我所属的租户 + 切换活跃租户(多租户)----
|
||
export interface MyTenant {
|
||
id: string;
|
||
name: string;
|
||
slug: string;
|
||
plan: string;
|
||
credit_balance_micro: number;
|
||
shared_billing: boolean;
|
||
members: number;
|
||
}
|
||
|
||
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
|
||
if (!res.ok) return { tenants: [], active: "" };
|
||
const d = (await res.json()) as { tenants?: MyTenant[]; active?: string };
|
||
return { tenants: d.tenants ?? [], active: d.active ?? "" };
|
||
}
|
||
|
||
export async function switchTenant(tenantId: string): Promise<void> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenant`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ tenant_id: tenantId }) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `switch failed: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
// ---- 共享工作区(Space,增量3):活跃空间上下文 + 切换 + 列表 + 建 ----
|
||
export interface SpaceCtx {
|
||
space: { id: string; name: string; kind: string; creator: string; archived: boolean } | null;
|
||
role: string; // 当前用户在此空间的角色(owner/admin/member/viewer)
|
||
}
|
||
export interface MySpace {
|
||
id: string;
|
||
tenant_id: string;
|
||
name: string;
|
||
kind: string; // personal / project / tenant
|
||
creator: string;
|
||
archived: boolean;
|
||
role: string;
|
||
members: number;
|
||
}
|
||
|
||
export async function spaceCurrent(): Promise<SpaceCtx | null> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/current`, { headers: bearer() }));
|
||
if (!res.ok) return null;
|
||
const d = (await res.json()) as Partial<SpaceCtx>;
|
||
return { space: d.space ?? null, role: d.role ?? "" };
|
||
}
|
||
|
||
export async function mySpaces(): Promise<{ spaces: MySpace[]; active: string }> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/spaces`, { headers: bearer() }));
|
||
if (!res.ok) return { spaces: [], active: "" };
|
||
const d = (await res.json()) as { spaces?: MySpace[]; active?: string };
|
||
return { spaces: d.spaces ?? [], active: d.active ?? "" };
|
||
}
|
||
|
||
export async function switchSpace(spaceId: string): Promise<void> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/space`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ space_id: spaceId }) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `switch space failed: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
export async function createSpace(name: string, kind = "project"): Promise<{ id: string }> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ name, kind }) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `create space failed: ${res.status}`);
|
||
}
|
||
return (await res.json()) as { id: string };
|
||
}
|
||
|
||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||
export interface UsageDay {
|
||
day: string; // YYYYMMDD
|
||
total_tok: number;
|
||
credits_micro: number;
|
||
cost_micros: number;
|
||
task_count: number;
|
||
}
|
||
export interface UsageRecent {
|
||
task_id: string;
|
||
model: string;
|
||
total_tok: number;
|
||
credits_micro: number;
|
||
cost_micros: number;
|
||
currency: string;
|
||
ts: number;
|
||
}
|
||
export interface MyUsage {
|
||
from: string;
|
||
to: string;
|
||
balance_micro: number;
|
||
credit_enforce: boolean;
|
||
trend: UsageDay[];
|
||
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
|
||
recent: UsageRecent[];
|
||
}
|
||
|
||
export async function myUsage(days = 30): Promise<MyUsage> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() }));
|
||
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
|
||
const d = (await res.json()) as Partial<MyUsage>;
|
||
return {
|
||
from: d.from ?? "",
|
||
to: d.to ?? "",
|
||
balance_micro: d.balance_micro ?? 0,
|
||
credit_enforce: !!d.credit_enforce,
|
||
trend: d.trend ?? [],
|
||
totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 },
|
||
recent: d.recent ?? [],
|
||
};
|
||
}
|
||
|
||
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
|
||
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
|
||
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}`, { headers: bearer() }));
|
||
if (!res.ok) throw new Error(`status failed: ${res.status}`);
|
||
const d = (await res.json()) as { status?: string; detail?: string };
|
||
return { status: d.status ?? "", detail: d.detail ?? "" };
|
||
}
|
||
|
||
// approveTask: POST /api/v1/tasks/:id/approve —— HITL 人工审批决定(批准放行 / 拒绝中止)。
|
||
export async function approveTask(taskId: string, approved: boolean, opts?: { node?: string; note?: string }): Promise<void> {
|
||
const res = guard401(
|
||
await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/approve`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...bearer() },
|
||
body: JSON.stringify({ approved, node: opts?.node ?? "", note: opts?.note ?? "" }),
|
||
}),
|
||
);
|
||
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
|
||
}
|
||
|
||
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
||
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。
|
||
export function streamTokens(
|
||
taskId: string,
|
||
onToken: (t: string) => void,
|
||
onDone: () => void,
|
||
onError?: (e: unknown) => void,
|
||
): () => void {
|
||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`);
|
||
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
|
||
es.addEventListener("done", () => {
|
||
es.close();
|
||
onDone();
|
||
});
|
||
es.onerror = (e) => {
|
||
es.close();
|
||
onError?.(e);
|
||
};
|
||
return () => es.close();
|
||
}
|
||
|
||
// 执行轨迹事件(与后端 contract.ExecEvent 对应):运行·观测的节点级实时事件。
|
||
export interface ExecEvent {
|
||
seq: number;
|
||
ts: number;
|
||
node: string; // init / tool:wiki_search / prompt / model / plan / section:0 / render / task / compile
|
||
kind: string; // memory|tool|prompt|model|plan|section|render|system
|
||
phase: string; // start|end|error|info
|
||
label: string;
|
||
detail?: string;
|
||
ms?: number;
|
||
}
|
||
|
||
// streamExec: 订阅 SSE /api/v1/tasks/:id/exec —— 与 token 流并行,逐节点点亮执行轨迹。
|
||
export function streamExec(
|
||
taskId: string,
|
||
onEvent: (ev: ExecEvent) => void,
|
||
onDone: () => void,
|
||
onError?: (e: unknown) => void,
|
||
): () => void {
|
||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`);
|
||
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
|
||
es.addEventListener("done", () => {
|
||
es.close();
|
||
onDone();
|
||
});
|
||
es.onerror = (e) => {
|
||
es.close();
|
||
onError?.(e);
|
||
};
|
||
return () => es.close();
|
||
}
|
||
|
||
// 入库进度事件(与后端 contract.IngestEvent 对应)。
|
||
export interface IngestEvent {
|
||
stage: string;
|
||
msg?: string;
|
||
done?: number;
|
||
total?: number;
|
||
chunks?: string[];
|
||
preview?: string; // 解析阶段:解析出的文本片段
|
||
triples?: Triple[]; // 抽实体阶段:LLM 抽出的知识三元组
|
||
error?: string;
|
||
}
|
||
|
||
// idHeaders 把身份带进请求头:JWT(Bearer) 作鉴权与 owner 作用域;X-Session-ID 作多轮会话标识。
|
||
// 不再发 X-User-ID —— owner 由网关从已验证 JWT 取(伪造头无效)。
|
||
function idHeaders(id: Identity): Record<string, string> {
|
||
return { ...bearer(), "X-Session-ID": id.sessionId };
|
||
}
|
||
|
||
export interface KbInfo {
|
||
name: string;
|
||
kind: string; // folder / project / case / general
|
||
}
|
||
|
||
// listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。
|
||
export async function listKb(id: Identity): Promise<KbInfo[]> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) }));
|
||
const data = (await res.json()) as { kbs?: KbInfo[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`);
|
||
return data.kbs ?? [];
|
||
}
|
||
|
||
// createKb: POST /api/v1/kb/create —— 新建知识库(项目/案件/文件夹/通用)。
|
||
export async function createKb(id: Identity, name: string, kind: string): Promise<KbInfo> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/create`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ name, kind }),
|
||
});
|
||
const data = (await res.json()) as { name?: string; kind?: string; error?: string };
|
||
if (!res.ok || !data.name) throw new Error(data.error ?? `create failed: ${res.status}`);
|
||
return { name: data.name, kind: data.kind ?? kind };
|
||
}
|
||
|
||
export interface VaultDoc {
|
||
id: string; // 文件主表雪花 ID —— 选中/取正文/关联一律用它
|
||
name: string;
|
||
ext?: string; // 文件后缀(.md/.pdf…;笔记为空)
|
||
size?: number;
|
||
preview?: string;
|
||
}
|
||
export interface DocLink {
|
||
from: string; // 源文件 ID
|
||
to: string; // 目标文件 ID
|
||
}
|
||
|
||
// ---- 我的 Agent 编排(服务端保存,owner 隔离)----
|
||
export interface AgentInfo {
|
||
name: string;
|
||
graph: string; // {nodes,edges} JSON
|
||
updated_at?: string;
|
||
owner?: string; // 创建人 user.id(共享工作区)
|
||
creator?: string; // 创建人名字/邮箱(显示用)
|
||
mine?: boolean; // 是否本人创建(决定能否删/覆盖)
|
||
}
|
||
|
||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) }));
|
||
const data = (await res.json()) as { agents?: AgentInfo[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`);
|
||
return data.agents ?? [];
|
||
}
|
||
|
||
export async function saveAgent(id: Identity, name: string, graph: string): Promise<void> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/agents`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ name, graph }),
|
||
});
|
||
const data = (await res.json()) as { name?: string; error?: string };
|
||
if (!res.ok || !data.name) throw new Error(data.error ?? `save agent failed: ${res.status}`);
|
||
}
|
||
|
||
export async function deleteAgent(id: Identity, name: string): Promise<void> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/agents?name=${encodeURIComponent(name)}`, { method: "DELETE", headers: idHeaders(id) });
|
||
if (!res.ok) {
|
||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(data.error ?? `delete agent failed: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
// listChatModels: GET /api/v1/admin/models?kind=chat —— 已登记的对话模型名(供编排 Agent 节点选择)。
|
||
export async function listChatModels(): Promise<string[]> {
|
||
try {
|
||
const res = await fetch(`${GATEWAY}/api/v1/admin/models?kind=chat`);
|
||
const data = (await res.json()) as { models?: Array<{ model: string }> };
|
||
return (data.models ?? []).map((m) => m.model);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// listVault: GET /api/v1/kb/vault —— 文库列表(仅元数据 + 预览,不拉全文)。
|
||
export async function listVault(id: Identity, kb: string): Promise<VaultDoc[]> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
|
||
const data = (await res.json()) as { docs?: VaultDoc[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `vault failed: ${res.status}`);
|
||
return data.docs ?? [];
|
||
}
|
||
|
||
// getDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文(按需加载,避免列表拉全量)。
|
||
export async function getDoc(id: Identity, docId: string): Promise<string> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/doc?id=${encodeURIComponent(docId)}`, { headers: idHeaders(id) });
|
||
const data = (await res.json()) as { content?: string; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `doc failed: ${res.status}`);
|
||
return data.content ?? "";
|
||
}
|
||
|
||
// listLinks: GET /api/v1/kb/links —— 某库全部 [[双链]](反链/笔记关系图,数据小)。
|
||
export async function listLinks(id: Identity, kb: string): Promise<DocLink[]> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/links?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
|
||
const data = (await res.json()) as { links?: DocLink[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `links failed: ${res.status}`);
|
||
return data.links ?? [];
|
||
}
|
||
|
||
// saveNote: POST /api/v1/kb/note —— 新建/编辑笔记(落库 + 按 doc 重入库替换旧块)。
|
||
export async function saveNote(id: Identity, kb: string, name: string, content: string): Promise<void> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/note`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ kb, name, content }),
|
||
});
|
||
const data = (await res.json()) as { name?: string; error?: string };
|
||
if (!res.ok || !data.name) throw new Error(data.error ?? `save failed: ${res.status}`);
|
||
}
|
||
|
||
// ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
|
||
export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ kb, text }),
|
||
});
|
||
const data = (await res.json()) as { job_id?: string; error?: string };
|
||
if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest failed: ${res.status}`);
|
||
return data.job_id;
|
||
}
|
||
|
||
// ingestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。
|
||
export async function ingestFile(id: Identity, kb: string, file: File): Promise<string> {
|
||
const fd = new FormData();
|
||
fd.append("kb", kb);
|
||
fd.append("file", file);
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest_file`, { method: "POST", headers: idHeaders(id), body: fd });
|
||
const data = (await res.json()) as { job_id?: string; error?: string };
|
||
if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest file failed: ${res.status}`);
|
||
return data.job_id;
|
||
}
|
||
|
||
// streamIngest: SSE 订阅入库进度(/kb/ingest/:id/stream)。返回关闭函数。
|
||
export function streamIngest(
|
||
jobId: string,
|
||
onEvent: (ev: IngestEvent) => void,
|
||
onDone: () => void,
|
||
onError?: () => void,
|
||
): () => void {
|
||
const es = new EventSource(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`);
|
||
es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent));
|
||
es.addEventListener("done", () => {
|
||
es.close();
|
||
onDone();
|
||
});
|
||
es.onerror = () => {
|
||
es.close();
|
||
onError?.();
|
||
};
|
||
return () => es.close();
|
||
}
|
||
|
||
export interface KbHit {
|
||
text: string;
|
||
score: number;
|
||
}
|
||
|
||
export interface Triple {
|
||
s: string;
|
||
p: string;
|
||
o: string;
|
||
}
|
||
|
||
// graphKb: GET /api/v1/kb/graph —— 取某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。
|
||
export async function graphKb(id: Identity, kb: string): Promise<Triple[]> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
|
||
const data = (await res.json()) as { triples?: Triple[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `graph failed: ${res.status}`);
|
||
return data.triples ?? [];
|
||
}
|
||
|
||
// searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。
|
||
export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise<KbHit[]> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/kb/search`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ kb, q, topK }),
|
||
});
|
||
const data = (await res.json()) as { hits?: KbHit[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `search failed: ${res.status}`);
|
||
return data.hits ?? [];
|
||
}
|
||
|
||
// generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。
|
||
// 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。
|
||
export async function generateReport(id: Identity, topic: string, kb?: string): Promise<string> {
|
||
const res = guard401(
|
||
await fetch(`${GATEWAY}/api/v1/reports`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ topic, kb: kb ?? "" }),
|
||
}),
|
||
);
|
||
const data = (await res.json()) as { task_id?: string; error?: string };
|
||
if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`);
|
||
return data.task_id;
|
||
}
|
||
|
||
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
|
||
export function reportDownloadUrl(taskId: string): string {
|
||
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
|
||
}
|
||
|
||
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
||
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
||
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
|
||
}
|
||
|
||
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||
export async function setMemory(
|
||
id: Identity,
|
||
key: string,
|
||
value: string,
|
||
): Promise<string> {
|
||
const res = guard401(
|
||
await fetch(`${GATEWAY}/api/v1/memory`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||
body: JSON.stringify({ key, value }),
|
||
}),
|
||
);
|
||
const data = (await res.json()) as { message?: string; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
||
return data.message ?? "ok";
|
||
}
|
||
|
||
// 一条长期偏好(含读路径打分维度)。
|
||
export interface MemoryItem {
|
||
key: string;
|
||
value: string;
|
||
importance: number;
|
||
last_seen: string;
|
||
}
|
||
|
||
// listMemory: GET /api/v1/memory —— 列出当前用户偏好(已按 Score 降序)。
|
||
export async function listMemory(id: Identity): Promise<MemoryItem[]> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory`, { headers: idHeaders(id) }));
|
||
const data = (await res.json()) as { memories?: MemoryItem[]; error?: string };
|
||
if (!res.ok) throw new Error(data.error ?? `list memory failed: ${res.status}`);
|
||
return data.memories ?? [];
|
||
}
|
||
|
||
// deleteMemory: DELETE /api/v1/memory?key= —— 软删一条偏好。
|
||
export async function deleteMemory(id: Identity, key: string): Promise<void> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory?key=${encodeURIComponent(key)}`, { method: "DELETE", headers: idHeaders(id) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `delete memory failed: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
// ── 工作台仪表盘聚合(GET /stats/overview)──
|
||
export interface KeyCount { key: string; count: number }
|
||
export interface RecentRun { task_id: string; status: string; detail: string; at: string }
|
||
export interface Overview {
|
||
tasks_today: number;
|
||
tasks_total: number;
|
||
status_count: KeyCount[];
|
||
task_trend: KeyCount[];
|
||
token_trend: KeyCount[];
|
||
eval_avg: number;
|
||
faithful_avg: number;
|
||
eval_count: number;
|
||
kb_docs: number;
|
||
kb_count: number;
|
||
tokens_today: number;
|
||
daily_budget: number;
|
||
recent_runs: RecentRun[];
|
||
services: Record<string, boolean>;
|
||
}
|
||
|
||
export async function statsOverview(): Promise<Overview> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: bearer() }));
|
||
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
||
return res.json() as Promise<Overview>;
|
||
}
|
||
|
||
// ── 运行历史 / 复盘 ──
|
||
export interface RunSummary {
|
||
task_id: string;
|
||
status: string;
|
||
detail: string;
|
||
at: string;
|
||
eval_level: string;
|
||
eval_overall: number;
|
||
}
|
||
|
||
export async function listRuns(limit = 30): Promise<RunSummary[]> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/runs?limit=${limit}`, { headers: bearer() }));
|
||
if (!res.ok) throw new Error(`list runs failed: ${res.status}`);
|
||
const d = (await res.json()) as { runs?: RunSummary[] };
|
||
return d.runs ?? [];
|
||
}
|
||
|
||
export interface EvalResult {
|
||
overall: number;
|
||
rule: number;
|
||
llm: number;
|
||
faithful: number;
|
||
level: string;
|
||
flags: string[];
|
||
reason: string;
|
||
sources: number;
|
||
corrected: boolean;
|
||
}
|
||
|
||
// taskEval: 取一次任务的评测结果(无则返回 null)。
|
||
export async function taskEval(taskId: string): Promise<EvalResult | null> {
|
||
const res = await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/eval`, { headers: bearer() });
|
||
if (!res.ok) return null;
|
||
return res.json() as Promise<EvalResult>;
|
||
}
|
||
|
||
// runReplay: 历史运行复盘 —— 读库取持久化的输出 + 轨迹(不依赖 Redis TTL,秒回)。
|
||
export async function runReplay(taskId: string): Promise<{ output: string; exec: ExecEvent[] }> {
|
||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/replay`, { headers: bearer() }));
|
||
if (!res.ok) throw new Error(`replay failed: ${res.status}`);
|
||
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
||
return { output: d.output ?? "", exec: d.exec ?? [] };
|
||
}
|