Files
ARTEX/web/src/lib/api.ts
T

474 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
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)}`),
// ---- 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}`),
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 }),
),
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 }) =>
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 (Bash execution history) ----
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}`),
};