Files
ARTEX-chixuweihu/web/src/lib/api.ts
T
Autumn-27andClaude Opus 4.8 6bffd491ee feat(workspace): 新增工作空间文件管理器(针对 workDir)
后端 server/workspace.go(全部在 requireAuth 之后,路径限定在 workDir):
- list/read/write/mkdir/delete/download/upload 七个端点
- wsResolve 用 Clean("/"+rel)+前缀校验防 .. 遍历;上传经 filepath.Base
  再二次校验;读文本 2MB 上限(超限/二进制仅下载);上传 512MB 上限;
  下载文件名过滤 + filename* UTF-8 编码

前端:
- 侧边栏「工作空间」入口(/function/workspace)
- api/types:workspaceList/read/write/mkdir/delete + upload(FormData)
  + download(blob 带 token)
- page:面包屑导航 + 目录表格 + 上传/新建文件夹/刷新 + 每行下载/删除
  + 右侧 Sheet 文本编辑器(二进制/超大仅下载)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-09 01:01:19 -04:00

550 lines
29 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.
// 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, Activity,
Audit, TrafficResp, TrafficDetail, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
WorkspaceListing, WorkspaceFile,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("artex_token");
}
async function http<T>(path: string, init?: RequestInit): Promise<T> {
if (MOCK) return mockHandle<T>(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<string, string> | 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 = <T>(p: string) => http<T>(p);
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
const del = <T>(p: string) => http<T>(p, { method: "DELETE" });
// Go serializes nil slices as JSON null — coerce to [].
const arr = <T>(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) =>
post<Task>("/tasks", { description, goal, llm_profile_id: llmProfileId ?? null, timeout_seconds: timeoutSeconds ?? 0 }),
deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
setActive: (id: string) => post<{ active: string }>("/active", { id }),
// ---- stats ----
stats: (task?: string) => get<Stats>(`/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<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
taskAssetRefs: (id: string, assetId: number) =>
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
// ---- workspace file manager (workDir) ----
workspaceList: (path = "") =>
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
workspaceRead: (path: string) =>
get<WorkspaceFile>(`/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[]) => {
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) => {
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}`);
const 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<Record<string, number>>("/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<Company[]>("/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<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
intents: (task?: string) => get<TaskNode[]>(`/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<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
conversationTokens: () =>
get<ConvTokenSummary[]>("/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)}`),
// ---- traffic / audit / report / chat ----
audit: (task?: string) => get<Audit>(`/audit${tq(task)}`),
traffic: (page = 0, size = 100, host = "", method = "", q = "") =>
get<TrafficResp>(
`/traffic?page=${page}&size=${size}` +
(host ? `&host=${encodeURIComponent(host)}` : "") +
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
(q ? `&q=${encodeURIComponent(q)}` : ""),
),
trafficExchange: (id: string) =>
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
// ---- app settings (runtime toggles) ----
settings: () => get<Settings>(`/settings`),
setSettings: (patch: Partial<Settings>) => put<Settings>(`/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) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message }),
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<AgentDetail>(`/agents/${key}`),
createAgent: (key: string, name: string, description = "") =>
post<Agent>("/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<Conversation>("/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) =>
post<{ status: string }>(`/conversations/${id}/messages`, { message }),
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<AgentTrigger, "id" | "agent_key" | "last_fire">) =>
post<AgentTrigger>(`/agents/${key}/triggers`, t),
updateTrigger: (id: number, t: Omit<AgentTrigger, "id" | "agent_key" | "last_fire">) =>
patch<{ ok: boolean }>(`/triggers/${id}`, t),
deleteTrigger: (id: number) => del<{ deleted: number }>(`/triggers/${id}`),
saveAgentConfig: (
key: string,
patch: {
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<string, string>) =>
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<Tool, "description" | "schema" | "agents" | "enabled">) =>
put<{ ok: boolean }>(`/tools/${key}`, patch),
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
// custom tools (自定义工具)
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
post<{ key: string }>("/tools/custom", t),
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
put<{ ok: boolean }>(`/tools/custom/${key}`, t),
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
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<MCPServer>) => 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<string, number> }>(
`/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<string, number>;
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<InterceptRule, "id" | "created_at" | "updated_at">) =>
post<InterceptRule>("/intercept/rules", rule),
updateInterceptRule: (id: number, rule: Omit<InterceptRule, "id" | "created_at" | "updated_at">) =>
put<InterceptRule>(`/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<InterceptPending>(`/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<void> => {
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; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.model) sp.set("model", params.model);
if (params?.session) sp.set("session", params.session);
sp.set("page", String(params?.page ?? 0));
sp.set("size", String(params?.size ?? 50));
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
},
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
};