// Real backend client. /api/* is proxied to the Go backend (next.config rewrites). // Returns the domain types in lib/types.ts. Shapes match the backend handlers; // a few fields the backend serializes differently (e.g. created_at as a unix int) // are passed through and formatted at the call site. import type { ChatMention } from "@/lib/chat-mentions"; import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { ActiveFindingRetest, Activity, Agent, AgentDetail, AgentTrigger, ArchiveBatchItem, Asset, Audit, BatchCategoryItem, BatchControlItem, ChatAttachment, CommandRecord, Company, CompanyScopeMutation, CompanyScopeRule, Conversation, ConvTokenSummary, CoverageAssetRefs, CoverageGraphData, DailyTokenBucket, DeleteTaskOptions, DeleteTaskResult, Edge, EvidenceBodyPreview, ExplorationNodePage, ExplorationNodeQuery, Finding, FindingAssetTree, FindingDeepenResponse, FindingGroupsPage, FindingQuery, FindingRetest, FindingStats, FindingStatus, FindingsPage, FindingTraffic, FindingTrafficDetail, IntentAsset, InterceptApprovalFilter, InterceptApprovalRow, InterceptDetail, InterceptPending, InterceptRule, JudgeConfig, LLMPoolStatus, LLMProfile, LLMRecordDetail, LLMRecordItem, LLMRetryOverride, LLMRetryPolicy, LLMTask, MCPServer, MCPTool, MissingSkill, ModelTokenStat, PromptVar, PromptVersion, SessionTokenUsage, Settings, Severity, SkillCall, SkillItem, SSProject, SSTask, Stats, Task, TaskArchive, TaskArchivePage, TaskAssetMutation, TaskAssetScopeMutation, TaskCategory, TaskConstraint, TaskGoal, TaskLLMResolutions, TaskNode, TaskScopeRow, TaskTemplate, TokenTotal, TokenUsage, Tool, ToolStat, TrafficDetail, TrafficEvidenceRef, TrafficEvidenceRole, TrafficHost, TrafficResp, UpdateCheck, UsageStats, WorkspaceFile, WorkspaceListing, } from "@/lib/types"; function getToken(): string | null { if (typeof window === "undefined") return null; return localStorage.getItem("artex_token"); } export async function http(path: string, init?: RequestInit): Promise { if (MOCK) return mockHandle(init?.method ?? "GET", path, init?.body ?? null); const token = getToken(); const r = await fetch(`/api${path}`, { ...init, headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers as Record | undefined), }, }); if (r.status === 401) { if (typeof window !== "undefined") { localStorage.removeItem("artex_token"); document.cookie = "artex_token=; path=/; max-age=0"; window.location.href = "/login"; } throw new Error("未授权"); } if (!r.ok) { const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`; let message = fallback; try { const payload = (await r.json()) as { error?: unknown }; if (typeof payload.error === "string" && payload.error.trim()) { message = payload.error.trim(); } } catch { // Keep the status-based fallback for empty or non-JSON error responses. } throw new Error(message); } if (r.status === 204) return undefined as T; return r.json(); } // sseUrl builds a URL for Server-Sent Events streams. SSE must NOT go through the // Next.js dev `/api` rewrite: that proxy buffers the streamed response, so event // frames never reach the browser (the EventSource opens but receives 0 messages). // We therefore connect straight to the Go backend, whose CORS is open. Override // with NEXT_PUBLIC_SSE_BASE; set it to "" to force same-origin (e.g. behind a // production reverse proxy that flushes SSE correctly). // Token is appended as ?token= because SSE bypasses the Next.js proxy and the // browser does not send cookies cross-port. // mockReport returns a canned Markdown report for the demo. function mockReport(_task?: string): string { return `# ARTEX 渗透测试报告 — Acme Corp ## 概览 - 范围:acme.com(含 www / admin / api / shop / vpn 子域) - 已确认发现:6 项(高危 3 · 中危 3 · 低危 2) - 引擎模式:exploring ## 关键发现 1. **[高] 后台默认口令** admin.acme.com admin/admin123 → 可完全接管后台。 2. **[高] SQL 注入** www.acme.com/search?q= → 可读取 acme_prod 库。 3. **[高] IDOR** api.acme.com/v1/orders?id= → 可越权读取他人订单(含手机号/地址)。 4. **[中] 反射型 XSS**、**暴露 .git 源码**、**登录无速率限制**。 ## 建议 - 后台强制改密 + 启用 MFA、封禁默认口令。 - search 接口参数化查询、输出编码。 - API 增加对象级授权校验(IDOR)、更换强 JWT 密钥。 > (demo)本报告由 mock 数据生成,仅用于界面演示。`; } export function sseUrl(path: string): string { const base = process.env.NEXT_PUBLIC_SSE_BASE ?? (typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : ""); const token = getToken(); const sep = path.includes("?") ? "&" : "?"; return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; } const get = (p: string) => http(p); const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); const put = (p: string, body?: unknown) => http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); const patch = (p: string, body?: unknown) => http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); const del = (p: string, body?: unknown) => http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); // Go serializes nil slices as JSON null — coerce to []. const arr = (x: T[] | null | undefined): T[] => x ?? []; const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : ""); // findingFilterParams 把发现页的筛选条件序列化成 query string。列表 / 分组 / // 资产树 / 导出共用同一份,新增筛选项只改这里(后端也只解析这一份)。 function findingFilterParams(q: Omit): URLSearchParams { const p = new URLSearchParams(); if (q.severity && q.severity !== "all") p.set("severity", q.severity); if (q.status && q.status !== "all") p.set("status", q.status); if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass); if (q.task && q.task !== "all") p.set("task_id", q.task); if (q.query?.trim()) p.set("q", q.query.trim()); if (q.sort) p.set("sort", q.sort); if (q.assetScope) p.set("asset_scope", q.assetScope); return p; } function interceptPageQuery(page: number, size: number, filter: InterceptApprovalFilter) { const query = new URLSearchParams({ page: String(page), size: String(size) }); if (filter.status) query.set("status", filter.status); if (filter.decision_source) query.set("decision_source", filter.decision_source); return query.toString(); } export const api = { // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 health: () => get<{ ok: boolean; service: string; version: string }>("/health"), // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }), initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }), changePassword: (oldPassword: string, newPassword: string) => post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }), // ---- tasks ---- tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), task: (id: string) => get(`/tasks/${encodeURIComponent(id)}`), createTask: (input: { name?: string; categoryId?: number; description: string; goal: string; llmProfileIds?: number[]; sourceTaskIds?: string[]; companyIds?: number[]; timeoutSeconds?: number; seedFirstIntent?: boolean; planHeartbeatSeconds?: number; coverageEnabled?: boolean; }) => post("/tasks", { name: input.name ?? "", category_id: input.categoryId ?? null, description: input.description, goal: input.goal, llm_profile_ids: input.llmProfileIds ?? [], source_task_ids: input.sourceTaskIds ?? [], company_ids: input.companyIds ?? [], timeout_seconds: input.timeoutSeconds ?? 0, seed_first_intent: input.seedFirstIntent ?? false, plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 }), taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch(`/tasks/${id}`, input), renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }), pinTask: (id: string, pinned: boolean) => patch(`/tasks/${id}`, { pinned }), createTaskCategory: (name: string) => post("/task-categories", { name }), renameTaskCategory: (id: number, name: string) => patch(`/task-categories/${id}`, { name }), deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`), updateTaskCategory: (taskId: string, categoryId?: number) => patch(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }), // categoryId 省略/undefined = 移出分类(后端收到 null) updateTasksCategory: (taskIds: string[], categoryId?: number) => post<{ items: BatchCategoryItem[]; category: TaskCategory | null }>("/tasks/category/batch", { task_ids: taskIds, category_id: categoryId ?? null, }), taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), createTaskTemplate: (input: Pick) => post("/task-templates", input), updateTaskTemplate: (id: number, input: Partial>) => patch(`/task-templates/${id}`, input), deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`), updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) => put<{ id: string; llm_profile_ids: number[]; active_llm_profile_id?: number; llm_failover_state: string; reopened_intents: number; switch_event?: Activity; }>(`/tasks/${id}/llm`, { llm_profile_ids: llmProfileIds, active_llm_profile_id: activeLLMProfileId ?? null, }), deleteTask: (id: string, options: DeleteTaskOptions) => del(`/tasks/${id}`, options), controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean; queued: boolean; status: string }>(`/tasks/${id}/control`, { action }), controlTasksBatch: (taskIds: string[], action: "pause" | "resume") => post<{ items: BatchControlItem[] }>("/tasks/control/batch", { task_ids: taskIds, action }), taskArchives: (input?: { page?: number; size?: number; q?: string; state?: string }) => { const query = new URLSearchParams(); query.set("page", String(input?.page ?? 1)); query.set("size", String(input?.size ?? 20)); if (input?.q) query.set("q", input.q); if (input?.state) query.set("state", input.state); return get(`/task-archives?${query.toString()}`).then((response) => ({ ...response, items: arr(response.items), })); }, taskArchive: (id: number) => get(`/task-archives/${id}`), archiveTask: (id: string) => post(`/tasks/${id}/archive`), archiveTasks: (taskIds: string[]) => post<{ items: ArchiveBatchItem[] }>("/tasks/archive/batch", { task_ids: taskIds }), restoreTaskArchive: (id: number) => post(`/task-archives/${id}/restore`), restoreTaskArchives: (archiveIds: number[]) => post<{ items: ArchiveBatchItem[] }>("/task-archives/restore/batch", { archive_ids: archiveIds }), deleteTaskArchive: (id: number) => del(`/task-archives/${id}`), deleteTaskArchives: (archiveIds: number[]) => post<{ items: ArchiveBatchItem[] }>("/task-archives/delete/batch", { archive_ids: archiveIds }), controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) => post<{ id: number; state: "paused" | "open" | "stopped"; deleted?: { intents: number; facts: number; findings: number; activities: number }; }>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }), sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) => post<{ id: number; state: "running"; accepted: true; request_id: string; }>(`/tasks/${taskId}/intents/${intentId}/messages`, { message, request_id: requestId }), taskLLMResolution: (id: string) => get(`/tasks/${id}/llm/resolution`), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), setActive: (id: string) => post<{ active: string }>("/active", { id }), // ---- stats ---- stats: (task?: string) => get(`/stats${tq(task)}`), // 资产测试覆盖度(粗估,供参考):范围内资产被 fact 碰过的占比 + 按类型的 总数/已测。 taskCoverage: (id: string) => get<{ enabled: boolean; // 资产覆盖度功能是否开启;false 时其余字段为零值 scope_rows: number; denominator: number; tested: number; pct: number | null; by_type: { type: string; total: number; tested: number }[]; }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // ---- 目标管理(总览)---- // 本任务全部目标(text/vulnclass/state)。 taskGoals: (id: string) => get<{ goals: TaskGoal[] | null }>(`/tasks/${id}/goals`).then((response) => ({ goals: arr(response.goals) })), // 人工新增目标:写入图谱并通知 planner、复活任务。 addGoal: (id: string, text: string, vulnclass?: string) => post(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }), // 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。 updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) => patch(`/tasks/${id}/goals/${goalId}`, { text, vulnclass: vulnclass ?? "" }), // 人工删除目标(硬删除):通知 planner,删除不复活任务。 deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`), // ---- 操作约束管理(总览)---- // 本任务全部操作约束(allow/deny)。 taskConstraints: (id: string) => get<{ constraints: TaskConstraint[] | null }>(`/tasks/${id}/constraints`).then((response) => ({ constraints: arr(response.constraints), })), // 新增约束(不通知 planner,下一轮规划自然读到)。 addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) => post(`/tasks/${id}/constraints`, { text, kind }), // 修改约束(文本 + allow/deny)。 updateConstraint: (id: string, constraintId: string, text: string, kind: TaskConstraint["kind"]) => patch(`/tasks/${id}/constraints/${constraintId}`, { text, kind }), // 删除约束。 deleteConstraint: (id: string, constraintId: string) => del<{ ok: boolean }>(`/tasks/${id}/constraints/${constraintId}`), // 本任务测试范围列表(含继承自来源任务的范围)。 taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`), // 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。 addTaskScope: (id: string, kind: string, value: string, reason?: string) => post(`/tasks/${id}/scope`, { kind, value, reason }), // 删除本任务的一条测试范围。 deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`), // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 taskAssetRefs: (id: string, assetId: number) => get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), // ---- workspace file manager (workDir) ---- workspaceList: (path = "") => get(`/workspace/list?path=${encodeURIComponent(path)}`), workspaceRead: (path: string) => get(`/workspace/read?path=${encodeURIComponent(path)}`), workspaceWrite: (path: string, content: string) => post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }), workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), workspaceUpload: async (dir: string, files: File[]) => { if (MOCK) return { uploaded: files.length }; const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: fd, }); if (!r.ok) throw new Error(`上传失败: ${r.status}`); return r.json() as Promise<{ uploaded: number }>; }, workspaceDownload: async (path: string) => { let blob: Blob; if (MOCK) { blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" }); } else { const token = getToken(); const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, { headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, }); if (!r.ok) throw new Error(`下载失败: ${r.status}`); blob = await r.blob(); } const objUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objUrl; a.download = path.split("/").pop() || "download"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(objUrl); }, // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total. assets: (type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then( (r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }), ), searchAssets: (dsl: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`, ).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), assetCounts: (taskId = "") => get>(`/assets/counts${taskId ? `?task_id=${encodeURIComponent(taskId)}` : ""}`), deleteAssets: (ids: number[]) => http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }), // task-scoped view of the same endpoint — server-side paginated like `assets` taskAssets: (taskId: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?task_id=${encodeURIComponent(taskId)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`, ).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), // DSL search scoped to a task — the backend forces the task_id filter, so it // always stays within that task's assets (same DSL grammar as `searchAssets`). searchTaskAssets: (taskId: string, dsl: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?task_id=${encodeURIComponent(taskId)}&dsl=${encodeURIComponent(dsl)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`, ).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) => post(`/tasks/${taskId}/assets`, { asset_ids: assetIds, source_summary: sourceSummary, }), registerTaskAssetScopes: (taskId: string, scope: CompanyScopeRule[]) => post(`/tasks/${taskId}/assets`, { scope }), detachTaskAsset: (taskId: string, assetId: number) => del<{ detached: number }>(`/tasks/${taskId}/assets/${assetId}`), taskIntentAssets: (taskId: string) => get<{ assets: IntentAsset[] }>(`/tasks/${taskId}/intent-assets`).then((r) => arr(r.assets)), // ---- companies (企业 + 资产范围;归属唯一来源) ---- companies: () => get("/companies").then(arr), createCompany: (name: string, scope: CompanyScopeRule[]) => post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( "/companies", { name, scope, }, ), addCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") => post(`/companies/${id}/scope`, { scope, reason, }), updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") => post(`/companies/${id}/scope`, { scope, reason, reset: true, }), deleteCompany: (id: number, deleteAssets = false) => http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, { method: "DELETE", body: JSON.stringify({ delete_assets: deleteAssets }), }), // ---- exploration (per task) ---- frontier: (task?: string) => get(`/exploration/frontier${tq(task)}`).then(arr), findings: (task?: string) => get(`/exploration/findings${tq(task)}`).then(arr), findingsPage: (q: FindingQuery) => { const p = findingFilterParams(q); p.set("page", String(q.page)); p.set("limit", String(q.pageSize)); return get(`/exploration/findings?${p.toString()}`); }, findingGroups: (q: FindingQuery) => { const p = findingFilterParams(q); p.set("page", String(q.page)); p.set("limit", String(q.pageSize)); return get(`/exploration/findings/groups?${p.toString()}`); }, // findingAssetTree 取「按资产」视图的左侧树:只含有发现的资产及其祖先, // 节点带子树聚合计数。不分页——树是导航结构,一次取完。 findingAssetTree: (q: Omit) => get(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`), findingStats: () => get("/exploration/findings/stats"), // exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表); // scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。 exportFindings: async (opts: { format: "md-single" | "md-zip" | "csv" | "json"; scope: "filtered" | "all" | "selected"; filters?: Omit; ids?: string[]; }) => { const p = new URLSearchParams({ format: opts.format, scope: opts.scope }); if (opts.scope === "selected") { p.set("ids", (opts.ids ?? []).join(",")); } else if (opts.scope === "filtered" && opts.filters) { for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v); } const token = getToken(); const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(`export: ${r.status}`); const blob = await r.blob(); // 文件名优先取后端 Content-Disposition,取不到则用默认名。 const disp = r.headers.get("Content-Disposition") ?? ""; const m = disp.match(/filename="?([^"]+)"?/); const filename = m?.[1] ?? `findings-export`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }, getFinding: (id: string, contextTaskId?: string) => get( `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, ), findingTraffic: (id: string, contextTask?: string) => get( `/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, ), bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string) => post( `/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, { traffic_refs }, ), editFindingTraffic: ( id: string, bindingId: string, version: number, fields: { role: TrafficEvidenceRole; note: string }, contextTask?: string, ) => patch( `/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, { version, ...fields }, ), removeFindingTraffic: (id: string, bindingId: string, version: number, contextTask?: string) => del( `/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, { version }, ), orderFindingTraffic: (id: string, binding_ids: string[], version: number, contextTask?: string) => http( `/exploration/findings/${id}/traffic/order${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, { method: "PUT", body: JSON.stringify({ binding_ids, version }) }, ), findingTrafficDetail: (id: string, bindingId: string, contextTask?: string) => get( `/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, ), findingTrafficBody: ( id: string, bindingId: string, side: "request" | "response", offset: number, contextTask?: string, ) => get( `/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&offset=${offset}${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`, ), downloadFindingTrafficBody: async ( id: string, bindingId: string, side: "request" | "response", contextTask?: string, ) => { const token = getToken(); const response = await fetch( `/api/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&download=1${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ); if (!response.ok) { const error = await response.json().catch(() => ({ error: "下载失败" })); throw new Error(error.error ?? "下载失败"); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `evidence-${bindingId}-${side}.bin`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), setFindingStatus: (id: string, status: FindingStatus) => patch(`/exploration/findings/${id}`, { status }), setFindingSeverity: (id: string, severity: Severity) => patch(`/exploration/findings/${id}`, { severity }), // 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。 updateFinding: ( id: string, fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus }, ) => patch(`/exploration/findings/${id}`, fields), // 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。 deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`), findingRetests: (id: string) => get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests), ), activeFindingRetests: () => get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)), startFindingRetest: (id: string, notes: string) => post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes, }), deepenFinding: (id: string, description: string) => post(`/exploration/findings/${id}/deepen`, { description }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => get<{ workers: TokenUsage[]; sessions: SessionTokenUsage[]; total: TokenTotal }>( `/exploration/tokens${tq(task)}`, ).then((r) => ({ workers: arr(r.workers), sessions: arr(r.sessions), total: r.total, })), tokenDaily: (days = 30) => get(`/tokens/daily?days=${days}`).then(arr), conversationTokens: () => get("/tokens/conversations") .then(arr) .catch(() => [] as ConvTokenSummary[]), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), // 播报板:服务端按创建顺序分页的探索节点(默认最新在前)。 explorationNodes: (task: string, query: ExplorationNodeQuery = {}) => { const q = new URLSearchParams(); if (task) q.set("task", task); q.set("page", String(query.page ?? 1)); q.set("size", String(query.size ?? 20)); if (query.kinds?.length) q.set("kind", query.kinds.join(",")); if (query.states?.length) q.set("state", query.states.join(",")); if (query.q?.trim()) q.set("q", query.q.trim()); if (query.order === "asc") q.set("order", "asc"); return get(`/exploration/nodes?${q.toString()}`).then((r) => ({ items: arr(r.items), total: r.total ?? 0, page: r.page ?? 1, size: r.size ?? query.size ?? 20, edges: arr(r.edges), refs: r.refs ?? {}, assets: r.assets ?? {}, })); }, activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { const q = new URLSearchParams(); if (task) q.set("task", task); if (opts?.intent) q.set("intent", opts.intent); if (opts?.since) q.set("since", String(opts.since)); if (opts?.limit) q.set("limit", String(opts.limit)); return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, })); }, activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`), // Reverse-paginated session history. session = "main" | "plan" | "intent:". // before=0 → latest page; before= → the older page ending before that id. // snapshotCursor is the TASK-level max id at query time — open the task SSE at // since=snapshotCursor so history (id≤cursor) and the live tail (id>cursor) meet // gap-free. hasMore = still-older steps exist (drives scroll-up loading). activityHistory: (task: string, session: string, before = 0, limit = 200) => { const q = new URLSearchParams({ session, limit: String(limit) }); if (task) q.set("task", task); if (before > 0) q.set("before", String(before)); return get<{ items: Activity[]; snapshot_cursor: number; earliest_cursor: number; has_more: boolean }>( `/exploration/activity/history?${q.toString()}`, ).then((r) => ({ items: arr(r.items), snapshotCursor: r.snapshot_cursor ?? 0, earliestCursor: r.earliest_cursor ?? 0, hasMore: !!r.has_more, })); }, // Paged worker (intent) session list — reaches past the legacy fixed 300 cap. // before=0 → newest page; before= → older page. has_more = older intents exist. intentsPage: (task: string, before = 0, limit = 300) => { const q = new URLSearchParams({ limit: String(limit) }); if (task) q.set("task", task); q.set("before", String(before > 0 ? before : 0)); q.set("page", "1"); // marker so the backend returns the paged {items,has_more} shape return get<{ items: TaskNode[]; has_more: boolean }>(`/exploration/intents?${q.toString()}`).then((r) => ({ items: arr(r.items), hasMore: !!r.has_more, })); }, // Main-agent conversation segments of a task. Each segment is a resettable session // (clean transcript/context) over the same task; `current` is the writable one. mainSessions: (task: string) => get<{ sessions: { seq: number; created_at: string }[]; current: number }>( `/exploration/main-sessions${tq(task)}`, ).then((r) => ({ sessions: arr(r.sessions), current: r.current ?? 0 })), // Start a fresh main-agent session segment (does not touch the task's graph/assets/goal). newMainSession: (task: string) => post<{ seq: number; created_at: string; current: number }>(`/exploration/main-session/new${tq(task)}`), // ---- traffic / audit / report / chat ---- audit: (task?: string) => get(`/audit${tq(task)}`), traffic: (page = 0, size = 100, host = "", method = "", q = "") => get( `/traffic?page=${page}&size=${size}` + (host ? `&host=${encodeURIComponent(host)}` : "") + (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : ""), ), trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), setSettings: (patch: Partial) => put(`/settings`, patch), // Run a real "test" search with the given (or saved) config to verify it works. testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string; }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), report: async (task?: string) => { if (MOCK) return mockReport(task); const token = getToken(); const r = await fetch(`/api/report${tq(task)}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(`report: ${r.status}`); return r.text(); }, chatMentions: (kind: string, query: string, signal?: AbortSignal, cursor = "") => http<{ items: ChatMention[]; next_cursor?: string }>( `/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, { signal }, ), chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }), chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}`, })), }; const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: fd, }); if (!r.ok) throw new Error(`上传失败: ${r.status} ${await r.text()}`); return r.json() as Promise<{ attachments: ChatAttachment[] }>; }, stopChat: (taskId: string) => post<{ status: string }>(`/tasks/${taskId}/chat/stop`, {}), gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), // ---- LLM ---- getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; thinking_type?: string; reasoning_effort?: string; }>("/llm"), setLLM: ( provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0, ) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), testLLM: ( provider: string, model: string, base_url: string, api_key: string, proxy = "", thinking_type = "", reasoning_effort = "", profile_id?: number, streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里 session_header_key = "", // 非空=测试请求也带该自定义会话头(值为一次性 session id) ) => // reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败 post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", { provider, model, base_url, proxy, api_key, thinking_type, reasoning_effort, profile_id, streaming, session_header_key, }), llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)), saveLLMProfile: (p: { id?: number; // omit/0 = create; set = update that profile name: string; format: string; model: string; base_url?: string; proxy?: string; api_key?: string; // blank on update keeps the existing key rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; thinking_type?: string; // ""(不发送)|"disabled"|"enabled" reasoning_effort?: string; // ""(不发送)|"low"|"medium"|"high"|"xhigh"|"max" priority?: number; // 轮询顺位,越大越先用 pool_exclude?: boolean; // true=不作为故障转移目标 streaming?: boolean; // true(默认)=流式 | false=非流式 max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定 max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式) session_header_key?: string; // 非空=每次请求带该 HTTP 头,头值=当前会话 session id;""=不发送 retry?: LLMRetryOverride; // 本配置的重试覆盖;各项留 0 = 跟随全局重试策略 }) => post<{ id: number }>("/llm/profiles", p), deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`), activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }), // 轮询链的实际顺序 + 各配置的熔断状态。 llmPool: () => get("/llm/pool"), // 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。 resetLLMPool: (id?: string) => post("/llm/pool/reset", { id: id ? Number(id) : 0 }), // 全局重试策略(五层各自的次数+间隔)。全 0 = 全部走内置默认。 llmRetryPolicy: () => get("/llm/retry-policy"), saveLLMRetryPolicy: (p: LLMRetryPolicy) => post("/llm/retry-policy", p), fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) => post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", { provider, base_url, api_key, proxy, profile_id, }), // ---- agents ---- agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)), getAgent: (key: string) => get(`/agents/${key}`), createAgent: (key: string, name: string, description = "") => post("/agents", { key, name, description }), updateAgent: (key: string, name: string, description = "") => patch<{ ok: boolean }>(`/agents/${key}`, { name, description }), deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`), // ---- conversations (chat page) ---- conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)), createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) => post("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? null }), updateConversation: (id: number, input: { title?: string; pinned?: boolean }) => patch(`/conversations/${id}`, input), renameConversation: (id: number, title: string) => patch(`/conversations/${id}`, { title }), pinConversation: (id: number, pinned: boolean) => patch(`/conversations/${id}`, { pinned }), updateConversationProfile: (id: number, llm_profile_id: number | null) => patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }), deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`), deleteConversations: (ids: number[]) => post<{ items: { id: number; ok: boolean; error?: string }[] }>("/conversations/delete/batch", { ids }), // Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch. conversationMessages: (id: number, since = 0) => get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then( (r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running }), ), // Reverse pagination: the latest `limit` steps (before=0) or the page of older // steps ending before id `before`. hasMore = still-older steps exist. conversationHistory: (id: number, before = 0, limit = 200) => { const q = new URLSearchParams({ limit: String(limit) }); if (before > 0) q.set("before", String(before)); return get<{ items: Activity[]; cursor: number; running: boolean; hasMore: boolean }>( `/conversations/${id}/messages?${q.toString()}`, ).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running, hasMore: !!r.hasMore })); }, conversationMsgDetail: (id: number, seq: number) => get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""), sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) => post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }), stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}), saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}), // 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认; // max_turns 省略则不动、传 0=用内置默认轮数 saveAgentWrapup: (key: string, prompt: string, maxTurns?: number) => put<{ ok: boolean }>(`/agents/${key}/wrapup`, { prompt, max_turns: maxTurns }), resetAgentWrapup: (key: string) => post<{ ok: boolean; wrapup_default: string; wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/reset`, {}), // 任务级超时收尾词(仅 worker/planner);prompt 空=清除、用内置默认 saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) => put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }), resetAgentTaskTimeoutWrapup: (key: string) => post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>( `/agents/${key}/wrapup/task-timeout/reset`, {}, ), // P3 triggers (仅自定义 agent) agentTriggers: (key: string) => get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)), createTrigger: (key: string, t: Omit) => post(`/agents/${key}/triggers`, t), updateTrigger: (id: number, t: Omit) => patch<{ ok: boolean }>(`/triggers/${id}`, t), deleteTrigger: (id: number) => del<{ deleted: number }>(`/triggers/${id}`), saveAgentConfig: ( key: string, patch: { llm_profile_id?: number | null; // number=绑定;null=解绑(跟随任务/全局);缺省=不动 max_turns?: number; run_seconds?: number; web_search?: boolean; interactive_shell?: boolean; trigger_run_mode?: "serial" | "parallel"; trigger_merge_mode?: "by_task" | "all" | "none"; trigger_max_parallel?: number; }, ) => put<{ ok: boolean }>(`/agents/${key}/config`, patch), agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), previewAgentPrompt: (key: string, template: string, sample?: Record) => post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }), getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`), setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), // ---- tools (内置工具目录) ---- tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)), saveTool: (key: string, patch: Pick) => put<{ ok: boolean }>(`/tools/${key}`, patch), resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}), // custom tools (自定义工具) createCustomTool: ( t: Pick, ) => post<{ key: string }>("/tools/custom", t), updateCustomTool: ( key: string, t: Pick, ) => put<{ ok: boolean }>(`/tools/custom/${key}`, t), deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`), testCustomTool: (body: { kind: string; exec: Record; params: Record }) => post<{ output: string; is_error: boolean }>("/tools/custom/test", body), detectPython: () => post<{ python_interpreter: string }>("/settings/python/detect", {}), // ---- mcp ---- mcpServers: () => get<{ servers: MCPServer[] }>("/mcp").then((r) => arr(r.servers)), saveMcpServer: (m: Partial) => post<{ id: number }>("/mcp", m), deleteMcpServer: (id: number) => del<{ deleted: number }>(`/mcp/${id}`), mcpTools: (id: number) => get<{ tools: MCPTool[] }>(`/mcp/${id}/tools`).then((r) => arr(r.tools)), refreshMcpServer: (id: number) => post<{ tools: MCPTool[] }>(`/mcp/${id}/refresh`, {}).then((r) => arr(r.tools)), // ---- 资产同步 (ScopeSentry 数据源) ---- ssStatus: () => get<{ exists: boolean; configured: boolean; enabled: boolean; reachable: boolean; url?: string; tools: string[] }>( "/sync/scopesentry/status", ), ssDatasource: (body: { url?: string; api_key?: string }) => post<{ id: number; enabled: boolean }>("/sync/scopesentry/datasource", body), ssProjects: (page = 1, size = 50, search = "") => get<{ projects: SSProject[]; tag: Record }>( `/sync/scopesentry/projects?page=${page}&size=${size}${search ? `&search=${encodeURIComponent(search)}` : ""}`, ).then((r) => ({ projects: arr(r.projects), tag: r.tag ?? {} })), ssTasks: (page = 1, size = 50, search = "") => get<{ tasks: SSTask[] }>( `/sync/scopesentry/tasks?page=${page}&size=${size}${search ? `&search=${encodeURIComponent(search)}` : ""}`, ).then((r) => arr(r.tasks)), ssSync: (body: { dimension: "project" | "task"; targets: string[]; asset_types: string[]; create_company?: boolean; page_size?: number; }) => post<{ synced: Record; companies: string[] | null; warnings: string[] | null; errors: string[] | null; }>("/sync/scopesentry/sync", body), // ---- skills (文件系统) ---- skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)), createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string; }) => post<{ name: string }>("/skills", s), // uploadSkill installs a skill from a .zip (multipart). Surfaces the backend // error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message. uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => { if (MOCK) return { name: file.name.replace(/\.zip$/i, ""), files: 1 }; const fd = new FormData(); fd.append("file", file); const token = getToken(); const r = await fetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, { method: "POST", body: fd, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(body?.error || `上传失败(${r.status})`); return body; }, deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`), updateSkillMeta: ( name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }, ) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta), createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files), readSkillFile: (name: string, file: string) => get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content), writeSkillFile: (name: string, file: string, content: string) => put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }), deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`), skillUsage: (name: string, limit = 50) => get<{ calls: SkillCall[] }>(`/skills/${name}/usage?limit=${limit}`).then((r) => arr(r.calls)), missingSkills: (limit = 20) => get<{ missing: MissingSkill[] }>(`/skills/missing?limit=${limit}`).then((r) => arr(r.missing)), // ---- visibility (MCP resource side) ---- (agent ids are strings per spec) resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) => post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }), // ---- visibility (Skill,按名称) ---- skillVisibility: (name: string) => get<{ agents: string[] }>(`/visibility/skill/${name}`).then((r) => arr(r.agents)), toggleSkillVisibility: (agentId: string, skillName: string, visible: boolean) => post<{ ok: boolean }>("/visibility/skill/toggle", { agent_id: agentId, skill_name: skillName, visible }), // ---- intercept rules ---- interceptRules: () => get<{ rules: InterceptRule[] }>("/intercept/rules").then((r) => arr(r.rules)), createInterceptRule: (rule: Omit) => post("/intercept/rules", rule), updateInterceptRule: (id: number, rule: Omit) => put(`/intercept/rules/${id}`, rule), deleteInterceptRule: (id: number) => del<{ deleted: number }>(`/intercept/rules/${id}`), toggleInterceptRule: (id: number, enabled: boolean) => post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }), // ---- intercept pending (ask) ---- interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)), interceptGetOne: (id: number) => get(`/intercept/pending/${id}`), interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), interceptExecution: (id: number, conversationId?: number) => get( `/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`, ), interceptDetail: (id: number) => get(`/intercept/history/${id}`), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) => get<{ items: InterceptApprovalRow[]; total?: number }>( `/intercept/history?${interceptPageQuery(page, size, filter)}`, ).then((r) => ({ items: arr(r.items), total: r.total ?? r.items?.length ?? 0, })), interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) => get<{ items: InterceptApprovalRow[]; total?: number }>( `/intercept/task/${encodeURIComponent(taskId)}?${interceptPageQuery(page, size, filter)}`, ).then((r) => ({ items: arr(r.items), total: r.total ?? r.items?.length ?? 0, })), // ---- intercept tool-config (全局工具拦截范围) ---- interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { if (MOCK) return { enabled_tools: ["bash"] }; const token = getToken(); const r = await fetch("/api/intercept/tool-config", { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(await r.text()); return r.json(); }, interceptSetToolConfig: async (enabledTools: string[]): Promise => { if (MOCK) return; const token = getToken(); const r = await fetch("/api/intercept/tool-config", { method: "PUT", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ enabled_tools: enabledTools }), }); if (!r.ok) throw new Error(await r.text()); }, // ---- intercept LLM judge (模型兜底审批,全局配置) ---- interceptGetJudgeConfig: () => get("/intercept/judge"), interceptSetJudgeConfig: (cfg: JudgeConfig) => put<{ ok: boolean }>("/intercept/judge", cfg), // ---- commands (tool execution history, any tool) ---- commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => { const sp = new URLSearchParams(); if (params?.task) sp.set("task", params.task); if (params?.q) sp.set("q", params.q); sp.set("page", String(params?.page ?? 0)); sp.set("size", String(params?.size ?? 50)); return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`); }, // 各工具调用次数;沿用列表的 task/q 筛选,统计的是整个结果集而非当前页。 commandStats: (params?: { task?: string; q?: string }) => { const sp = new URLSearchParams(); if (params?.task) sp.set("task", params.task); if (params?.q) sp.set("q", params.q); return get<{ stats: ToolStat[] }>(`/commands/stats?${sp}`); }, // ---- LLM records ---- llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => { const sp = new URLSearchParams(); if (params?.model) sp.set("model", params.model); if (params?.session) sp.set("session", params.session); if (params?.task) sp.set("task", params.task); if (params?.page !== undefined) sp.set("page", String(params.page)); sp.set("size", String(params?.size ?? 50)); return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`); }, llmRecordDetail: (id: number) => get(`/llm/records/${id}`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), // 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确, // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。 tokensByModel: (task: string) => get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`), // ---- 一键更新 ---- // 检查以后端为准:下载是后端做的,浏览器能连 GitHub 而服务器连不上的情况很常见 // (服务器在内网、代理只配在浏览器上),那时点更新必然失败。 // 后端对 GitHub 的查询结果有 30 分钟缓存(未认证的 GitHub API 是 60 次/小时/IP, // 顶栏每次整页加载都会查一次,不缓存会很快耗光配额)。force=true 强制回源, // 留给用户显式点「检查更新」时用。 checkUpdate: (force = false) => get(`/update/check${force ? "?force=1" : ""}`), // 202 即返回,实际下载在后台跑,进度走 /api/update/stream。 applyUpdate: () => post<{ ok: boolean; target: string }>(`/update/apply`), rollbackUpdate: () => post<{ ok: boolean }>(`/update/rollback`), };