测试连接此前只判 err:请求通了但模型一个字不吐(思考烧光预算、正文被 安全策略吞掉、兼容层丢 content)照样报成功,而这种配置在会话里就是不 回话——正是「测试成功、对话不成功」的来源。 - TestConnection 返回回复文本,空回复直接判失败 - 回复回传前端,成功 toast 里展示,测试通过有据可查 - 测试连接改用该配置真实的收发模式:此前恒走流式(ConfigFrom 默认 Stream=true),选了非流式的配置测的是另一条通道;请求省略 streaming 时仍默认流式,老客户端不受影响 Closes #65
974 lines
48 KiB
TypeScript
974 lines
48 KiB
TypeScript
// 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 {
|
||
Activity,
|
||
Agent,
|
||
AgentDetail,
|
||
AgentTrigger,
|
||
Asset,
|
||
Audit,
|
||
BatchCategoryItem,
|
||
BatchControlItem,
|
||
ChatAttachment,
|
||
CommandRecord,
|
||
Company,
|
||
CompanyScopeMutation,
|
||
CompanyScopeRule,
|
||
Conversation,
|
||
ConvTokenSummary,
|
||
CoverageAssetRefs,
|
||
CoverageGraphData,
|
||
DailyTokenBucket,
|
||
DeleteTaskOptions,
|
||
DeleteTaskResult,
|
||
Edge,
|
||
Finding,
|
||
FindingDeepenResponse,
|
||
FindingGroupsPage,
|
||
FindingQuery,
|
||
FindingStats,
|
||
FindingStatus,
|
||
FindingsPage,
|
||
IntentAsset,
|
||
InterceptApprovalRow,
|
||
InterceptPending,
|
||
InterceptRule,
|
||
JudgeConfig,
|
||
LLMPoolStatus,
|
||
LLMProfile,
|
||
LLMRecordDetail,
|
||
LLMRecordItem,
|
||
LLMTask,
|
||
MCPServer,
|
||
MCPTool,
|
||
MissingSkill,
|
||
ModelTokenStat,
|
||
PromptVar,
|
||
PromptVersion,
|
||
SessionTokenUsage,
|
||
Settings,
|
||
Severity,
|
||
SkillCall,
|
||
SkillItem,
|
||
SSProject,
|
||
SSTask,
|
||
Stats,
|
||
Task,
|
||
TaskAssetMutation,
|
||
TaskAssetScopeMutation,
|
||
TaskCategory,
|
||
TaskConstraint,
|
||
TaskGoal,
|
||
TaskLLMResolutions,
|
||
TaskNode,
|
||
TaskScopeRow,
|
||
TaskTemplate,
|
||
TokenTotal,
|
||
TokenUsage,
|
||
Tool,
|
||
ToolStat,
|
||
TrafficDetail,
|
||
TrafficHost,
|
||
TrafficResp,
|
||
UsageStats,
|
||
WorkspaceFile,
|
||
WorkspaceListing,
|
||
} 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) {
|
||
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 = <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, body?: unknown) =>
|
||
http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||
|
||
// 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 = {
|
||
// 后端应用版本号(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 ?? "" })),
|
||
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<Task>("/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<Task>(`/tasks/${id}`, input),
|
||
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
||
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
|
||
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
|
||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||
updateTaskCategory: (taskId: string, categoryId?: number) =>
|
||
patch<Task>(`/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<TaskTemplate, "name" | "description" | "goal">) =>
|
||
post<TaskTemplate>("/task-templates", input),
|
||
updateTaskTemplate: (id: number, input: Partial<Pick<TaskTemplate, "name" | "description" | "goal">>) =>
|
||
patch<TaskTemplate>(`/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<DeleteTaskResult>(`/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 }),
|
||
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 ?? "" }),
|
||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/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>(`/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<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||
// ---- 目标管理(总览)----
|
||
// 本任务全部目标(text/vulnclass/state)。
|
||
taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`),
|
||
// 人工新增目标:写入图谱并通知 planner、复活任务。
|
||
addGoal: (id: string, text: string, vulnclass?: string) =>
|
||
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
|
||
// 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。
|
||
updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) =>
|
||
patch<TaskGoal>(`/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[] }>(`/tasks/${id}/constraints`),
|
||
// 新增约束(不通知 planner,下一轮规划自然读到)。
|
||
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
|
||
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
|
||
// 修改约束(文本 + allow/deny)。
|
||
updateConstraint: (id: string, constraintId: string, text: string, kind: TaskConstraint["kind"]) =>
|
||
patch<TaskConstraint>(`/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<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
||
// 删除本任务的一条测试范围。
|
||
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||
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[]) => {
|
||
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<Record<string, number>>(`/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 })),
|
||
attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) =>
|
||
post<TaskAssetMutation>(`/tasks/${taskId}/assets`, {
|
||
asset_ids: assetIds,
|
||
source_summary: sourceSummary,
|
||
}),
|
||
registerTaskAssetScopes: (taskId: string, scope: CompanyScopeRule[]) =>
|
||
post<TaskAssetScopeMutation>(`/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<Company[]>("/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<CompanyScopeMutation>(`/companies/${id}/scope`, {
|
||
scope,
|
||
reason,
|
||
}),
|
||
updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
|
||
post<CompanyScopeMutation>(`/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<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
|
||
findings: (task?: string) => get<Finding[]>(`/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.task && q.task !== "all") p.set("task_id", q.task);
|
||
if (q.sort) p.set("sort", q.sort);
|
||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||
},
|
||
findingGroups: (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.task && q.task !== "all") p.set("task_id", q.task);
|
||
if (q.sort) p.set("sort", q.sort);
|
||
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
|
||
},
|
||
findingStats: () => get<FindingStats>("/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<FindingQuery, "page" | "pageSize">;
|
||
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) {
|
||
const f = opts.filters;
|
||
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
|
||
if (f.status && f.status !== "all") p.set("status", f.status);
|
||
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
|
||
if (f.task && f.task !== "all") p.set("task_id", f.task);
|
||
if (f.sort) p.set("sort", f.sort);
|
||
}
|
||
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<Finding>(
|
||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||
),
|
||
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
|
||
findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||
setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||
setFindingSeverity: (id: string, severity: Severity) => patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
|
||
updateFinding: (
|
||
id: string,
|
||
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
|
||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
||
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
|
||
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
|
||
deepenFinding: (id: string, description: string) =>
|
||
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
|
||
intents: (task?: string) => get<TaskNode[]>(`/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<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)}`),
|
||
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
|
||
// before=0 → latest page; before=<id> → 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=<id> → 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>(`/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)}`),
|
||
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>(`/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, attachments?: ChatAttachment[]) =>
|
||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||
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, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里
|
||
) =>
|
||
// 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,
|
||
}),
|
||
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=非流式
|
||
}) => 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<LLMPoolStatus>("/llm/pool"),
|
||
// 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。
|
||
resetLLMPool: (id?: string) => post<LLMPoolStatus>("/llm/pool/reset", { id: id ? Number(id) : 0 }),
|
||
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 }),
|
||
updateConversation: (id: number, input: { title?: string; pinned?: boolean }) =>
|
||
patch<Conversation>(`/conversations/${id}`, input),
|
||
renameConversation: (id: number, title: string) => patch<Conversation>(`/conversations/${id}`, { title }),
|
||
pinConversation: (id: number, pinned: boolean) => patch<Conversation>(`/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<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: {
|
||
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<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}`),
|
||
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<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());
|
||
},
|
||
|
||
// ---- intercept LLM judge (模型兜底审批,全局配置) ----
|
||
interceptGetJudgeConfig: () => get<JudgeConfig>("/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<LLMRecordDetail>(`/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)}`),
|
||
};
|