Files
sundynix-agentix/sundynix-desktop/frontend/src/lib/api.ts
T
Blizzard 1344bf98a0 feat(desktop): 工作台重做为实时仪表盘 + /stats/overview 聚合端点(Tier1 UI 升级)
桌面端首屏原是静态宣传页(stat 全硬编码、唯一活数据是网关在线),"太单调"。
重做为活的驾驶舱:

后端 GET /api/v1/stats/overview(聚合,几条轻量查询):
- 任务今日/累计、7 日趋势、近 7 天终态分布(实例级,Task 无 owner)
- 评测均分 + 忠实度均值 + 计数;知识库文档/库数(owner 级)
- token 今日 + 7 日趋势(Redis 日计数)、近期运行 feed、服务健康(复用 health 口径)
- store.StatsOverview + RecentTasks。

前端 Home 重写为仪表盘:4 指标卡(今日任务/Token/评测均分/知识库)带 SVG 火花线、
近期运行 feed(状态点+相对时间,点进运行观测)、7 日任务量柱图、服务健康灯带、能力入口、
快捷动作。5s 轮询刷新。配色沿用现有 ink 暗底 + brand/accent。

live(vite dev + 预览截图):登录后仪表盘渲染真实活数据(今日任务/Token/评测 1.00/近期运行/
服务全绿),无控制台报错。tsc+vite 构建通过,gateway 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:13:54 +08:00

486 lines
19 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) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { task_id: string };
return data.task_id;
}
// 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;
}
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>;
}