feat(tasks): 总览页支持任务测试范围的查看/增删

- db: 新增 DeleteTaskScope;AddAgentScope 加 source 参数区分 agent/manual
- server: 新增 POST/DELETE /api/tasks/{id}/scope 端点
- web: 总览页新增「测试范围」卡片,可按类型手动增删,继承范围只读

Claude-Session: https://claude.ai/code/session_014TfZWeVGhjXqZD3vcj44mz
This commit is contained in:
Autumn-27 committed 2026-08-20 09:36:28 -04:00
1 parent 56101b46e8
commit 6b79043d82
8 files changed
+488 -193

No files matched your search

+1 -1
View File
@@ -382,7 +382,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
var added []map[string]any var added []map[string]any
errs := map[string]string{} errs := map[string]string{}
for i, e := range items { for i, e := range items {
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason) ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent")
if err != nil { if err != nil {
errs[strconv.Itoa(i)] = err.Error() errs[strconv.Itoa(i)] = err.Error()
continue continue
+3 -3
View File
@@ -356,13 +356,13 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
_ = d.DeleteTask(grand.ID) _ = d.DeleteTask(grand.ID)
_, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID}) _, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID})
}) })
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test"); err != nil { if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test", "agent"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test"); err != nil { if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test", "agent"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test"); err != nil { if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test", "agent"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil { if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil {
+18 -3
View File
@@ -113,11 +113,15 @@ func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip st
return nil return nil
} }
// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent'). // AddAgentScope parses a (kind,value) pair and records it as task scope.
// source is "agent" when called from an LLM tool, "manual" from the UI.
// company: value = company name or id (must already exist). root_domain/subdomain: // company: value = company name or id (must already exist). root_domain/subdomain:
// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128). // value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128).
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) { func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) {
ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason} if source == "" {
source = "agent"
}
ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason}
if taskID <= 0 { if taskID <= 0 {
return ts, fmt.Errorf("需要 task_id") return ts, fmt.Errorf("需要 task_id")
} }
@@ -184,6 +188,17 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (Ta
return ts, nil return ts, nil
} }
// DeleteTaskScope removes a single scope row by id, scoped to the given task.
// Returns whether a row was actually deleted.
func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) {
res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ListTaskScope returns all scope rows for a task. // ListTaskScope returns all scope rows for a task.
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) { func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
+60
View File
@@ -647,6 +647,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/workspace/download", s.wsDownload) mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
@@ -1634,6 +1636,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"scope": rows}) writeJSON(w, 200, map[string]any{"scope": rows})
} }
func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
as := s.m.Assets()
if as == nil {
writeErr(w, 503, "asset store 未启用")
return
}
var body struct {
Kind string `json:"kind"`
Value string `json:"value"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "invalid JSON")
return
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual")
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, ts)
}
func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
as := s.m.Assets()
if as == nil {
writeErr(w, 503, "asset store 未启用")
return
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64)
if err != nil {
writeErr(w, 400, "invalid scope id")
return
}
deleted, err := as.DeleteTaskScope(taskID, scopeID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if !deleted {
writeErr(w, 404, "scope row not found")
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) { func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task")) t := s.m.ResolveTask(r.URL.Query().Get("task"))
if t == nil { if t == nil {
@@ -1,27 +1,49 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { import {
ActivityIcon, ActivityIcon,
AlertTriangleIcon, AlertTriangleIcon,
BugIcon, BugIcon,
ClockIcon, ClockIcon,
PlusIcon,
RefreshCwIcon, RefreshCwIcon,
ShieldCheckIcon, ShieldCheckIcon,
TargetIcon, TargetIcon,
Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Task, Stats, TaskNode, Finding } from "@/lib/types"; import type { Finding, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
// 测试范围一条的显示值:域名 / 网段 / 公司。
function scopeValue(row: TaskScopeRow): string {
if (row.domain) return row.domain;
if (row.net) return row.net;
if (row.company_id) return `company #${row.company_id}`;
return "—";
}
const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
company: "公司",
root_domain: "根域名",
subdomain: "子域名",
ip: "IP",
cidr: "网段",
};
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
auto: "自动",
agent: "Agent",
manual: "手动",
};
function StatCard({ function StatCard({
label, label,
@@ -42,9 +64,7 @@ function StatCard({
</CardDescription> </CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle> <CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader> </CardHeader>
{sub && ( {sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
)}
</Card> </Card>
); );
} }
@@ -63,6 +83,46 @@ export function OverviewTab({ taskId }: { taskId: string }) {
} | null>(null); } | null>(null);
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。 // 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set()); const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
// 测试范围列表 + 新增表单状态。
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
const [scopeValueInput, setScopeValueInput] = React.useState("");
const [scopeBusy, setScopeBusy] = React.useState(false);
const [scopeErr, setScopeErr] = React.useState("");
const loadScope = React.useCallback(async () => {
try {
const resp = await api.taskScope(taskId);
setScope(resp.scope);
} catch {
// 忽略:无 asset store 时接口 503,范围卡片自然为空
}
}, [taskId]);
const addScope = async () => {
const value = scopeValueInput.trim();
if (!value) return;
setScopeBusy(true);
setScopeErr("");
try {
await api.addTaskScope(taskId, scopeKind, value);
setScopeValueInput("");
await loadScope();
} catch (e) {
setScopeErr(e instanceof Error ? e.message : "添加失败");
} finally {
setScopeBusy(false);
}
};
const removeScope = async (row: TaskScopeRow) => {
setScope((prev) => prev.filter((s) => s.id !== row.id));
try {
await api.deleteTaskScope(taskId, row.id);
} catch {
await loadScope(); // 删除失败:重新拉取还原
}
};
const markRerun = (key: string, on: boolean) => const markRerun = (key: string, on: boolean) =>
setRerunning((prev) => { setRerunning((prev) => {
@@ -77,9 +137,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
markRerun(id, true); markRerun(id, true);
try { try {
await api.rerunIntent(taskId, id); await api.rerunIntent(taskId, id);
setIntents((prev) => setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)));
prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)),
);
} catch { } catch {
// 失败忽略:下次轮询仍显示 blocked,用户可再点 // 失败忽略:下次轮询仍显示 blocked,用户可再点
} finally { } finally {
@@ -92,9 +150,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
markRerun("__all__", true); markRerun("__all__", true);
try { try {
await api.rerunBlocked(taskId); await api.rerunBlocked(taskId);
setIntents((prev) => setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)));
prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)),
);
} catch { } catch {
// ignore // ignore
} finally { } finally {
@@ -107,13 +163,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const load = async () => { const load = async () => {
try { try {
const [tasksResp, statsResp, intentsResp, findingsResp] = const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
await Promise.all([ api.tasks(),
api.tasks(), api.stats(taskId),
api.stats(taskId), api.intents(taskId),
api.intents(taskId), api.findings(taskId),
api.findings(taskId), ]);
]);
if (cancelled) return; if (cancelled) return;
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null); setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
setStats(statsResp); setStats(statsResp);
@@ -133,20 +188,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
}; };
load(); load();
void loadScope();
const timer = setInterval(load, 3000); const timer = setInterval(load, 3000);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(timer); clearInterval(timer);
}; };
}, [taskId]); }, [taskId, loadScope]);
const running = intents.filter((i) => i.state === "running"); const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open"); const open = intents.filter((i) => i.state === "open");
const blocked = intents.filter((i) => i.state === "blocked"); const blocked = intents.filter((i) => i.state === "blocked");
const taskFindings = findings.filter((f) => f.task_id === taskId); const taskFindings = findings.filter((f) => f.task_id === taskId);
const goalsPct = task?.goals_total const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0;
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
: 0;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -160,15 +214,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">描述</div> <div className="text-xs font-medium text-muted-foreground">描述</div>
<p className="text-sm whitespace-pre-wrap break-words"> <p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
{task?.description?.trim() || "—"}
</p>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">目标</div> <div className="text-xs font-medium text-muted-foreground">目标</div>
<p className="text-sm whitespace-pre-wrap break-words"> <p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
{task?.goal?.trim() || "—"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -205,6 +255,86 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheckIcon className="size-4 text-emerald-500" /> 测试范围
<span className="text-muted-foreground text-xs font-normal">
(覆盖度分母 + 授权边界,共 {scope.length} 条)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
size="sm"
value={scopeKind}
onChange={(e) => setScopeKind(e.target.value as TaskScopeRow["kind"])}
>
<NativeSelectOption value="root_domain">根域名</NativeSelectOption>
<NativeSelectOption value="subdomain">子域名</NativeSelectOption>
<NativeSelectOption value="ip">IP</NativeSelectOption>
<NativeSelectOption value="cidr">网段</NativeSelectOption>
<NativeSelectOption value="company">公司</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 w-56 text-sm"
placeholder={
scopeKind === "company"
? "公司名或 id"
: scopeKind === "ip" || scopeKind === "cidr"
? "如 10.0.0.1 或 10.0.0.0/24"
: "如 example.com"
}
value={scopeValueInput}
onChange={(e) => setScopeValueInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addScope();
}}
disabled={scopeBusy}
/>
<Button
size="sm"
variant="outline"
disabled={scopeBusy || !scopeValueInput.trim()}
onClick={() => void addScope()}
>
<PlusIcon className="size-3.5" /> 添加
</Button>
{scopeErr && <span className="text-xs text-red-500">{scopeErr}</span>}
</div>
{/* 范围列表 */}
{scope.length > 0 ? (
<div className="flex flex-col gap-1.5">
{scope.map((row) => (
<div key={row.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{SCOPE_KIND_LABELS[row.kind]}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{scopeValue(row)}</span>
<span className="text-muted-foreground shrink-0 text-xs">{SCOPE_SOURCE_LABELS[row.source]}</span>
{row.task_id.toString() === taskId ? (
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
onClick={() => void removeScope(row)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
) : (
<span className="text-muted-foreground shrink-0 text-xs">继承</span>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">暂无测试范围,添加后可作为资产覆盖度的分母。</p>
)}
</CardContent>
</Card>
{/* Heartbeat */} {/* Heartbeat */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -224,17 +354,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">运行中 Worker</div> <div className="text-xs text-muted-foreground">运行中 Worker</div>
<div className="mt-1 text-lg font-semibold tabular-nums"> <div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
{running.length}
</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">最近活动</div> <div className="text-xs text-muted-foreground">最近活动</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm"> <div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" /> <ClockIcon className="size-3.5" />
{task?.last_activity {task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
: "—"}
</div> </div>
</div> </div>
<div> <div>
@@ -270,9 +396,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{i.payload}</span> <span className="min-w-0 flex-1 truncate">{i.payload}</span>
</div> </div>
))} ))}
{running.length === 0 && ( {running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -284,27 +408,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm"> <CardContent className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<div className="text-2xl font-semibold tabular-nums text-red-600"> <div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
{taskFindings.length}
</div>
<div className="text-xs text-muted-foreground">确认漏洞</div> <div className="text-xs text-muted-foreground">确认漏洞</div>
</div> </div>
<div> <div>
<div className="text-2xl font-semibold tabular-nums text-blue-600"> <div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
{running.length}
</div>
<div className="text-xs text-muted-foreground">执行中</div> <div className="text-xs text-muted-foreground">执行中</div>
</div> </div>
<div> <div>
<div className="text-2xl font-semibold tabular-nums"> <div className="text-2xl font-semibold tabular-nums">{open.length}</div>
{open.length}
</div>
<div className="text-xs text-muted-foreground">frontier 待领</div> <div className="text-xs text-muted-foreground">frontier 待领</div>
</div> </div>
<div> <div>
<div className="text-2xl font-semibold tabular-nums text-red-600"> <div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
{blocked.length}
</div>
<div className="text-xs text-muted-foreground">被拦意图</div> <div className="text-xs text-muted-foreground">被拦意图</div>
</div> </div>
</CardContent> </CardContent>
@@ -323,9 +439,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{f.summary}</span> <span className="min-w-0 flex-1 truncate">{f.summary}</span>
</div> </div>
))} ))}
{taskFindings.length === 0 && ( {taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
<p className="text-sm text-muted-foreground">暂无发现</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -337,19 +451,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0"> <CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-sm"> <CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图 <AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
<span className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
(共 {blocked.length} 条,可重跑)
</span>
</CardTitle> </CardTitle>
<Button <Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
size="sm" <RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
variant="outline"
disabled={rerunning.has("__all__")}
onClick={() => void rerunAll()}
>
<RefreshCwIcon
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
/>
全部重跑 全部重跑
</Button> </Button>
</CardHeader> </CardHeader>
@@ -365,9 +470,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
disabled={rerunning.has(i.id)} disabled={rerunning.has(i.id)}
onClick={() => void rerunOne(i.id)} onClick={() => void rerunOne(i.id)}
> >
<RefreshCwIcon <RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
/>
重跑 重跑
</Button> </Button>
</div> </div>
@@ -383,24 +486,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
{/* Stat cards */} {/* Stat cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<StatCard <StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
label="待领意图" <StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
value={open.length} <StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
icon={ShieldCheckIcon}
sub="frontier 开放"
/>
<StatCard
label="确认发现"
value={taskFindings.length}
icon={BugIcon}
sub="本任务"
/>
<StatCard
label="意图总数"
value={intents.length}
icon={AlertTriangleIcon}
sub="本任务全部意图"
/>
</div> </div>
</div> </div>
); );
+207 -93
View File
@@ -6,15 +6,59 @@
import { MOCK } from "@/lib/mock/enabled"; import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler"; import { mockHandle } from "@/lib/mock/handler";
import type { import type {
Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, Activity,
FindingsPage, FindingStats, FindingQuery, Activity, Agent,
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, AgentDetail,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, AgentTrigger,
Tool, Conversation, AgentTrigger, ChatAttachment, Asset,
InterceptRule, InterceptPending, InterceptApprovalRow, AssetNode,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, Audit,
WorkspaceListing, WorkspaceFile, ChatAttachment,
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus, CommandRecord,
Company,
Conversation,
ConvTokenSummary,
CoverageAssetRefs,
CoverageGraphData,
DailyTokenBucket,
DeleteTaskOptions,
DeleteTaskResult,
Edge,
Finding,
FindingQuery,
FindingStats,
FindingStatus,
FindingsPage,
InterceptApprovalRow,
InterceptPending,
InterceptRule,
LLMPoolStatus,
LLMProfile,
LLMRecordDetail,
LLMRecordItem,
LLMTask,
MCPServer,
MCPTool,
PromptVar,
PromptVersion,
Settings,
Severity,
SkillItem,
SSProject,
SSTask,
Stats,
Task,
TaskAssetView,
TaskNode,
TaskScopeRow,
TokenTotal,
TokenUsage,
Tool,
TrafficDetail,
TrafficHost,
TrafficResp,
WorkspaceFile,
WorkspaceListing,
} from "@/lib/types"; } from "@/lib/types";
function getToken(): string | null { function getToken(): string | null {
@@ -92,19 +136,21 @@ function mockReport(_task?: string): string {
export function sseUrl(path: string): string { export function sseUrl(path: string): string {
const base = const base =
process.env.NEXT_PUBLIC_SSE_BASE ?? process.env.NEXT_PUBLIC_SSE_BASE ??
(typeof window !== "undefined" (typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : "");
? `${window.location.protocol}//${window.location.hostname}:8787`
: "");
const token = getToken(); const token = getToken();
const sep = path.includes("?") ? "&" : "?"; const sep = path.includes("?") ? "&" : "?";
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
} }
const get = <T>(p: string) => http<T>(p); 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 post = <T>(p: string, body?: unknown) =>
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); http<T>(p, { method: "POST", 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 put = <T>(p: string, body?: unknown) =>
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); 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 []. // Go serializes nil slices as JSON null — coerce to [].
const arr = <T>(x: T[] | null | undefined): T[] => x ?? []; const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
@@ -116,15 +162,14 @@ export const api = {
// ---- auth ---- // ---- auth ----
authStatus: () => get<{ initialized: boolean }>("/auth/status"), authStatus: () => get<{ initialized: boolean }>("/auth/status"),
login: (username: string, password: string) => login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
post<{ token: string }>("/auth/login", { username, password }), initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
initPassword: (password: string) =>
post<{ token: string }>("/auth/init", { password }),
changePassword: (oldPassword: string, newPassword: string) => changePassword: (oldPassword: string, newPassword: string) =>
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }), post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
// ---- tasks ---- // ---- tasks ----
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), tasks: () =>
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (input: { createTask: (input: {
description: string; description: string;
goal: string; goal: string;
@@ -155,9 +200,9 @@ export const api = {
llm_profile_ids: llmProfileIds, llm_profile_ids: llmProfileIds,
active_llm_profile_id: activeLLMProfileId ?? null, active_llm_profile_id: activeLLMProfileId ?? null,
}), }),
deleteTask: (id: string, options: DeleteTaskOptions) => deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
del<DeleteTaskResult>(`/tasks/${id}`, options), controlTask: (id: string, action: "pause" | "resume") =>
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") => controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
post<{ post<{
id: number; id: number;
@@ -165,7 +210,8 @@ export const api = {
deleted?: { intents: number; facts: number; findings: number; activities: number }; deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }), }>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), rerunIntent: (taskId: string, intentId: string) =>
post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
setActive: (id: string) => post<{ active: string }>("/active", { id }), setActive: (id: string) => post<{ active: string }>("/active", { id }),
@@ -181,23 +227,24 @@ export const api = {
by_type: { type: string; total: number; tested: number }[]; by_type: { type: string; total: number; tested: number }[];
}>(`/tasks/${id}/coverage`), }>(`/tasks/${id}/coverage`),
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
taskCoverageGraph: (id: string) => taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`), // 本任务测试范围列表(含继承自来源任务的范围)。
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) => taskAssetRefs: (id: string, assetId: number) => get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
// ---- workspace file manager (workDir) ---- // ---- workspace file manager (workDir) ----
workspaceList: (path = "") => workspaceList: (path = "") => get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`), workspaceRead: (path: string) => get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
workspaceRead: (path: string) =>
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
workspaceWrite: (path: string, content: string) => workspaceWrite: (path: string, content: string) =>
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }), post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
workspaceMkdir: (path: string) => workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
workspaceDelete: (path: string) =>
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
workspaceUpload: async (dir: string, files: File[]) => { workspaceUpload: async (dir: string, files: File[]) => {
if (MOCK) return { uploaded: files.length }; if (MOCK) return { uploaded: files.length };
const fd = new FormData(); const fd = new FormData();
@@ -236,8 +283,9 @@ export const api = {
// ---- assets ---- // ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total. // Server-side paginated: pass limit/offset, get back the page + full match total.
assets: (type = "", limit = 50, offset = 0) => assets: (type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`) get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then(
.then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), (r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }),
),
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) => searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>( get<{ count: number; total: number; assets: Asset[] }>(
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`, `/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
@@ -257,18 +305,38 @@ export const api = {
createCompany: (name: string, logo: string, scope: string) => createCompany: (name: string, logo: string, scope: string) =>
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
"/companies", "/companies",
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] }, {
name,
logo,
scope: scope.trim()
? scope
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
: [],
},
), ),
addCompanyScope: (id: number, scope: string, reason = "") => addCompanyScope: (id: number, scope: string, reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
`/companies/${id}/scope`, scope: scope.trim()
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason }, ? scope
), .split("\n")
.map((s) => s.trim())
.filter(Boolean)
: [],
reason,
}),
updateCompanyScope: (id: number, scope: string, reason = "") => updateCompanyScope: (id: number, scope: string, reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
`/companies/${id}/scope`, scope: scope.trim()
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true }, ? scope
), .split("\n")
.map((s) => s.trim())
.filter(Boolean)
: [],
reason,
reset: true,
}),
deleteCompany: (id: number, deleteAssets = false) => deleteCompany: (id: number, deleteAssets = false) =>
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, { http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
method: "DELETE", method: "DELETE",
@@ -293,12 +361,9 @@ export const api = {
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
), ),
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
findingLineage: (id: string) => findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
setFindingStatus: (id: string, status: FindingStatus) => setFindingSeverity: (id: string, severity: Severity) => patch<Finding>(`/exploration/findings/${id}`, { severity }),
patch<Finding>(`/exploration/findings/${id}`, { status }),
setFindingSeverity: (id: string, severity: Severity) =>
patch<Finding>(`/exploration/findings/${id}`, { severity }),
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。 // 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
updateFinding: ( updateFinding: (
id: string, id: string,
@@ -312,10 +377,11 @@ export const api = {
workers: arr(r.workers), workers: arr(r.workers),
total: r.total, total: r.total,
})), })),
tokenDaily: (days = 30) => tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
conversationTokens: () => conversationTokens: () =>
get<ConvTokenSummary[]>("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]), get<ConvTokenSummary[]>("/tokens/conversations")
.then(arr)
.catch(() => [] as ConvTokenSummary[]),
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
@@ -323,7 +389,10 @@ export const api = {
if (opts?.intent) q.set("intent", opts.intent); if (opts?.intent) q.set("intent", opts.intent);
if (opts?.since) q.set("since", String(opts.since)); if (opts?.since) q.set("since", String(opts.since));
if (opts?.limit) q.set("limit", String(opts.limit)); 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 })); 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)}`), activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`),
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>". // Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
@@ -366,20 +435,21 @@ export const api = {
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
(q ? `&q=${encodeURIComponent(q)}` : ""), (q ? `&q=${encodeURIComponent(q)}` : ""),
), ),
trafficExchange: (id: string) => trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
trafficDeleteHost: (host: string) => trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
trafficDeleteHosts: (hosts: string[]) =>
del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
// ---- app settings (runtime toggles) ---- // ---- app settings (runtime toggles) ----
settings: () => get<Settings>(`/settings`), settings: () => get<Settings>(`/settings`),
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch), setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
// Run a real "test" search with the given (or saved) config to verify it works. // 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 }) => testWebSearch: (patch: {
post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, 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) => { report: async (task?: string) => {
if (MOCK) return mockReport(task); if (MOCK) return mockReport(task);
const token = getToken(); const token = getToken();
@@ -393,7 +463,15 @@ export const api = {
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { 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}` })) }; 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(); const fd = new FormData();
for (const f of files) fd.append("file", f); for (const f of files) fd.append("file", f);
const token = getToken(); const token = getToken();
@@ -409,9 +487,29 @@ export const api = {
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
// ---- LLM ---- // ---- 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"), getLLM: () =>
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) => get<{
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), 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: ( testLLM: (
provider: string, provider: string,
model: string, model: string,
@@ -464,8 +562,7 @@ export const api = {
// ---- agents ---- // ---- agents ----
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)), agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`), getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
createAgent: (key: string, name: string, description = "") => createAgent: (key: string, name: string, description = "") => post<Agent>("/agents", { key, name, description }),
post<Agent>("/agents", { key, name, description }),
updateAgent: (key: string, name: string, description = "") => updateAgent: (key: string, name: string, description = "") =>
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }), patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`), deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`),
@@ -497,7 +594,8 @@ export const api = {
sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) => sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) =>
post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }), post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }),
stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}), stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}),
saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), saveAgentPrompt: (key: string, template: string, note = "") =>
put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}), resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}),
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认; // 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
// max_turns 省略则不动、传 0=用内置默认轮数 // max_turns 省略则不动、传 0=用内置默认轮数
@@ -509,7 +607,10 @@ export const api = {
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) => saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }), put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
resetAgentTaskTimeoutWrapup: (key: string) => resetAgentTaskTimeoutWrapup: (key: string) =>
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}), post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(
`/agents/${key}/wrapup/task-timeout/reset`,
{},
),
// P3 triggers (仅自定义 agent) // P3 triggers (仅自定义 agent)
agentTriggers: (key: string) => agentTriggers: (key: string) =>
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)), get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
@@ -531,12 +632,15 @@ export const api = {
trigger_max_parallel?: number; trigger_max_parallel?: number;
}, },
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch), ) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), agentPromptVersions: (key: string) =>
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), 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>) => previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }), post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`), 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 }), setAgentVisibility: (key: string, mcp: number[], skill: string[]) =>
put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
// ---- tools (内置工具目录) ---- // ---- tools (内置工具目录) ----
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)), tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
@@ -544,10 +648,13 @@ export const api = {
put<{ ok: boolean }>(`/tools/${key}`, patch), put<{ ok: boolean }>(`/tools/${key}`, patch),
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}), resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
// custom tools (自定义工具) // custom tools (自定义工具)
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) => createCustomTool: (
post<{ key: string }>("/tools/custom", t), t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) => ) => post<{ key: string }>("/tools/custom", t),
put<{ ok: boolean }>(`/tools/custom/${key}`, 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}`), deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) => testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
post<{ output: string; is_error: boolean }>("/tools/custom/test", body), post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
@@ -591,8 +698,14 @@ export const api = {
// ---- skills (文件系统) ---- // ---- skills (文件系统) ----
skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.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 }) => createSkill: (s: {
post<{ name: string }>("/skills", 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 // 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. // 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 }> => { uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => {
@@ -610,20 +723,21 @@ export const api = {
return body; return body;
}, },
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`), deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) => updateSkillMeta: (
put<{ ok: boolean }>(`/skills/${name}/meta`, meta), name: string,
createSkillDir: (skill: string, path: string) => meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string },
post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), ) => 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), skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files),
readSkillFile: (name: string, file: string) => readSkillFile: (name: string, file: string) =>
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content), get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
writeSkillFile: (name: string, file: string, content: string) => writeSkillFile: (name: string, file: string, content: string) =>
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }), put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
deleteSkillPath: (skill: string, path: string) => deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec) // ---- 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)), 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) => toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) =>
post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }), post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }),
@@ -648,7 +762,8 @@ export const api = {
interceptDecide: (id: number, decision: "allowed" | "denied") => interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), 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)), interceptTask: (taskId: string) =>
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
// ---- intercept tool-config (全局工具拦截范围) ---- // ---- intercept tool-config (全局工具拦截范围) ----
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
@@ -696,6 +811,5 @@ export const api = {
}, },
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`), llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
llmRecordsDeleteTask: (task: string) => llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
}; };
+6
View File
@@ -149,6 +149,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph; if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph;
if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0)); if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0));
// ── 任务测试范围(增删查)──
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] };
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST")
return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" };
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true };
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})── // ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? ""); if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
+12
View File
@@ -225,6 +225,18 @@ export interface WorkspaceFile {
content?: string; content?: string;
} }
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
export interface TaskScopeRow {
id: number;
task_id: number;
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
company_id?: number;
domain?: string;
net?: string;
source: "auto" | "agent" | "manual";
reason?: string;
}
// 公司资产范围规则的一条(归属唯一真值来源)。 // 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow { export interface ScopeRow {
id: number; id: number;