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:
1 parent
56101b46e8
commit
6b79043d82
8 files changed
+483
-188
No files matched your search
@@ -382,7 +382,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||||
var added []map[string]any
|
||||
errs := map[string]string{}
|
||||
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 {
|
||||
errs[strconv.Itoa(i)] = err.Error()
|
||||
continue
|
||||
|
||||
@@ -356,13 +356,13 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
|
||||
_ = d.DeleteTask(grand.ID)
|
||||
_, _ = 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil {
|
||||
|
||||
+18
-3
@@ -113,11 +113,15 @@ func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip st
|
||||
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:
|
||||
// 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) {
|
||||
ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason}
|
||||
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) {
|
||||
if source == "" {
|
||||
source = "agent"
|
||||
}
|
||||
ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason}
|
||||
if taskID <= 0 {
|
||||
return ts, fmt.Errorf("需要 task_id")
|
||||
}
|
||||
@@ -184,6 +188,17 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (Ta
|
||||
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.
|
||||
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
|
||||
rows, err := s.db.Query(`
|
||||
|
||||
@@ -647,6 +647,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
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("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||
if t == nil {
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
Trash2Icon,
|
||||
} 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 { 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 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({
|
||||
label,
|
||||
@@ -42,9 +64,7 @@ function StatCard({
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
{sub && (
|
||||
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
|
||||
)}
|
||||
{sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +83,46 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
} | null>(null);
|
||||
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
|
||||
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) =>
|
||||
setRerunning((prev) => {
|
||||
@@ -77,9 +137,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
markRerun(id, true);
|
||||
try {
|
||||
await api.rerunIntent(taskId, id);
|
||||
setIntents((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)),
|
||||
);
|
||||
setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)));
|
||||
} catch {
|
||||
// 失败忽略:下次轮询仍显示 blocked,用户可再点
|
||||
} finally {
|
||||
@@ -92,9 +150,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
markRerun("__all__", true);
|
||||
try {
|
||||
await api.rerunBlocked(taskId);
|
||||
setIntents((prev) =>
|
||||
prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)),
|
||||
);
|
||||
setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -107,8 +163,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [tasksResp, statsResp, intentsResp, findingsResp] =
|
||||
await Promise.all([
|
||||
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
|
||||
api.tasks(),
|
||||
api.stats(taskId),
|
||||
api.intents(taskId),
|
||||
@@ -133,20 +188,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
|
||||
load();
|
||||
void loadScope();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
}, [taskId, loadScope]);
|
||||
|
||||
const running = intents.filter((i) => i.state === "running");
|
||||
const open = intents.filter((i) => i.state === "open");
|
||||
const blocked = intents.filter((i) => i.state === "blocked");
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId);
|
||||
const goalsPct = task?.goals_total
|
||||
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
|
||||
: 0;
|
||||
const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">描述</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{task?.description?.trim() || "—"}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">目标</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{task?.goal?.trim() || "—"}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -205,6 +255,86 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -224,17 +354,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">运行中 Worker</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums">
|
||||
{running.length}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">最近活动</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{task?.last_activity
|
||||
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
|
||||
: "—"}
|
||||
{task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -270,9 +396,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
||||
</div>
|
||||
))}
|
||||
{running.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
|
||||
)}
|
||||
{running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -284,27 +408,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{taskFindings.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
|
||||
<div className="text-xs text-muted-foreground">确认漏洞</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">
|
||||
{running.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
|
||||
<div className="text-xs text-muted-foreground">执行中</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{open.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{open.length}</div>
|
||||
<div className="text-xs text-muted-foreground">frontier 待领</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{blocked.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
|
||||
<div className="text-xs text-muted-foreground">被拦意图</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -323,9 +439,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
|
||||
</div>
|
||||
))}
|
||||
{taskFindings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无发现</p>
|
||||
)}
|
||||
{taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -337,19 +451,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
(共 {blocked.length} 条,可重跑)
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rerunning.has("__all__")}
|
||||
onClick={() => void rerunAll()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
|
||||
<RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
|
||||
全部重跑
|
||||
</Button>
|
||||
</CardHeader>
|
||||
@@ -365,9 +470,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
disabled={rerunning.has(i.id)}
|
||||
onClick={() => void rerunOne(i.id)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
|
||||
重跑
|
||||
</Button>
|
||||
</div>
|
||||
@@ -383,24 +486,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="待领意图"
|
||||
value={open.length}
|
||||
icon={ShieldCheckIcon}
|
||||
sub="frontier 开放"
|
||||
/>
|
||||
<StatCard
|
||||
label="确认发现"
|
||||
value={taskFindings.length}
|
||||
icon={BugIcon}
|
||||
sub="本任务"
|
||||
/>
|
||||
<StatCard
|
||||
label="意图总数"
|
||||
value={intents.length}
|
||||
icon={AlertTriangleIcon}
|
||||
sub="本任务全部意图"
|
||||
/>
|
||||
<StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
|
||||
<StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
|
||||
<StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+207
-93
@@ -6,15 +6,59 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
Tool, Conversation, AgentTrigger, ChatAttachment,
|
||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
WorkspaceListing, WorkspaceFile,
|
||||
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus,
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
ChatAttachment,
|
||||
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";
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -92,19 +136,21 @@ function mockReport(_task?: string): string {
|
||||
export function sseUrl(path: string): string {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SSE_BASE ??
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol}//${window.location.hostname}:8787`
|
||||
: "");
|
||||
(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 });
|
||||
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 ?? [];
|
||||
@@ -116,15 +162,14 @@ export const api = {
|
||||
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
login: (username: string, password: string) =>
|
||||
post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) =>
|
||||
post<{ token: string }>("/auth/init", { password }),
|
||||
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 ?? "" })),
|
||||
tasks: () =>
|
||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (input: {
|
||||
description: string;
|
||||
goal: string;
|
||||
@@ -155,9 +200,9 @@ export const api = {
|
||||
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 }>(`/tasks/${id}/control`, { action }),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") =>
|
||||
post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
post<{
|
||||
id: number;
|
||||
@@ -165,7 +210,8 @@ export const api = {
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
// 重跑一条没跑成功的意图(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 时一键全部重试)。
|
||||
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
|
||||
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
||||
@@ -181,23 +227,24 @@ export const api = {
|
||||
by_type: { type: string; total: number; tested: number }[];
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) =>
|
||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
taskCoverageGraph: (id: string) => 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) =>
|
||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
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)}`),
|
||||
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)}`),
|
||||
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();
|
||||
@@ -236,8 +283,9 @@ export const api = {
|
||||
// ---- 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 })),
|
||||
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}`,
|
||||
@@ -257,18 +305,38 @@ export const api = {
|
||||
createCompany: (name: string, logo: string, scope: string) =>
|
||||
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
|
||||
"/companies",
|
||||
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] },
|
||||
{
|
||||
name,
|
||||
logo,
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
},
|
||||
),
|
||||
addCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
}),
|
||||
updateCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
reset: true,
|
||||
}),
|
||||
deleteCompany: (id: number, deleteAssets = false) =>
|
||||
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
|
||||
method: "DELETE",
|
||||
@@ -293,12 +361,9 @@ export const api = {
|
||||
`/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 }),
|
||||
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,
|
||||
@@ -312,10 +377,11 @@ export const api = {
|
||||
workers: arr(r.workers),
|
||||
total: r.total,
|
||||
})),
|
||||
tokenDaily: (days = 30) =>
|
||||
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
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)}`),
|
||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
@@ -323,7 +389,10 @@ export const api = {
|
||||
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 }));
|
||||
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>".
|
||||
@@ -366,20 +435,21 @@ export const api = {
|
||||
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
||||
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
||||
),
|
||||
trafficExchange: (id: string) =>
|
||||
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
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 }),
|
||||
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),
|
||||
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();
|
||||
@@ -393,7 +463,15 @@ export const api = {
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
|
||||
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) };
|
||||
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();
|
||||
@@ -409,9 +487,29 @@ export const api = {
|
||||
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
||||
|
||||
// ---- LLM ----
|
||||
getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"),
|
||||
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) =>
|
||||
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
getLLM: () =>
|
||||
get<{
|
||||
configured: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
proxy?: string;
|
||||
key_set: boolean;
|
||||
rate_per_second?: number;
|
||||
rate_per_minute?: number;
|
||||
context_window_k?: number;
|
||||
reasoning_effort?: string;
|
||||
}>("/llm"),
|
||||
setLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
base_url: string,
|
||||
api_key: string,
|
||||
rate_per_second = 0,
|
||||
rate_per_minute = 0,
|
||||
proxy = "",
|
||||
context_window_k = 0,
|
||||
) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
testLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
@@ -464,8 +562,7 @@ export const api = {
|
||||
// ---- 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 }),
|
||||
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}`),
|
||||
@@ -497,7 +594,8 @@ export const api = {
|
||||
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 }),
|
||||
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=用内置默认轮数
|
||||
@@ -509,7 +607,10 @@ export const api = {
|
||||
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`, {}),
|
||||
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)),
|
||||
@@ -531,12 +632,15 @@ export const api = {
|
||||
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)),
|
||||
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 }),
|
||||
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)),
|
||||
@@ -544,10 +648,13 @@ export const api = {
|
||||
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),
|
||||
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),
|
||||
@@ -591,8 +698,14 @@ export const api = {
|
||||
|
||||
// ---- 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),
|
||||
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 }> => {
|
||||
@@ -610,20 +723,21 @@ export const api = {
|
||||
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 }),
|
||||
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}`),
|
||||
deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
|
||||
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec)
|
||||
resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
|
||||
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 }),
|
||||
|
||||
@@ -648,7 +762,8 @@ export const api = {
|
||||
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)),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
|
||||
// ---- intercept tool-config (全局工具拦截范围) ----
|
||||
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
||||
@@ -696,6 +811,5 @@ export const api = {
|
||||
},
|
||||
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)}`),
|
||||
llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||
};
|
||||
@@ -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] === "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})──
|
||||
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
|
||||
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
||||
|
||||
@@ -225,6 +225,18 @@ export interface WorkspaceFile {
|
||||
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 {
|
||||
id: number;
|
||||
|
||||
Reference in new issue
Block a user