// 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 { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus, FindingsPage, FindingStats, FindingQuery, Activity, Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, Tool, Conversation, AgentTrigger, ChatAttachment, InterceptRule, InterceptPending, InterceptApprovalRow, TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, WorkspaceListing, WorkspaceFile, CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, } from "@/lib/types"; function getToken(): string | null { if (typeof window === "undefined") return null; return localStorage.getItem("artex_token"); } 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) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`); 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)}` : ""); export const api = { // ---- 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 ?? "" })), createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) => post("/tasks", { description, goal, llm_profile_id: llmProfileId ?? null, timeout_seconds: timeoutSeconds ?? 0, seed_first_intent: seedFirstIntent ?? false, plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) }), deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`), controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), // 重跑一条没跑成功的意图(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<{ 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`), // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 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: () => get>("/assets/counts"), deleteAssets: (ids: number[]) => http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }), // legacy — kept for task-specific views; hits the same endpoint with task_id filter taskAssets: (taskId: string, type = "") => get<{ count: number; assets: Asset[] }>(`/assets?task_id=${taskId}&type=${type}`) .then((r) => r?.assets ?? []), // ---- companies (企业 + 资产范围;归属唯一来源) ---- companies: () => get("/companies").then(arr), createCompany: (name: string, logo: string, scope: string) => post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( "/companies", { name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] }, ), addCompanyScope: (id: number, scope: string, reason = "") => post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( `/companies/${id}/scope`, { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason }, ), updateCompanyScope: (id: number, scope: string, reason = "") => post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( `/companies/${id}/scope`, { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], 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 = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) }); 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.sort) p.set("sort", q.sort); return get(`/exploration/findings?${p.toString()}`); }, findingStats: () => get("/exploration/findings/stats"), setFindingStatus: (id: string, status: FindingStatus) => patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({ workers: arr(r.workers), 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)}`), 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, })); }, // ---- 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(); }, chat: (message: string, task?: string, attachments?: ChatAttachment[]) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), // 方式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; 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 = "", reasoning_effort = "", profile_id?: number, ) => post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", { provider, model, base_url, proxy, api_key, reasoning_effort, profile_id, }), 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; reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max" }) => 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) }), 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 }), renameConversation: (id: number, title: string) => patch<{ ok: boolean }>(`/conversations/${id}`, { title }), 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}`), // 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}`), // ---- 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 }), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), // ---- 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()); }, // ---- 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}`); }, // ---- 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)}`), };