Files
sundynix-agentix/sundynix-desktop/frontend/src/lib/api.ts
T
Blizzard addaa1b34f feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)
引入 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>
2026-07-13 10:51:33 +08:00

678 lines
26 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.
// 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_filemultipart)—— 文件入库(异步,返回 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_graphNeo4j)。
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 ?? [] };
}