同步
This commit is contained in:
1 parent
e67ff26e90
commit
58f23a2773
61 files changed
+11611
-443
No files matched your search
@@ -34,6 +34,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { api } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -111,11 +112,19 @@ function Composer({
|
||||
}) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const atts = attachments ?? [];
|
||||
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
|
||||
const sendMode = useChatSendMode();
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
<<<<<<< Updated upstream
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
=======
|
||||
if (!shouldSubmitOnKey(e, sendMode)) return;
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
>>>>>>> Stashed changes
|
||||
}
|
||||
return (
|
||||
<div className="border-t p-3">
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function FindingsPage() {
|
||||
<SelectItem value="all">全部任务</SelectItem>
|
||||
{(stats.tasks ?? []).map((t) => {
|
||||
const id = String(t.id);
|
||||
const label = t.description || `任务 #${id}(已删除)`;
|
||||
const label = t.name || t.description || `任务 #${id}(已删除)`;
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
@@ -361,9 +361,98 @@ export default function FindingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
<Card className="py-0">
|
||||
<CardContent className="px-0">
|
||||
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
|
||||
=======
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
const groupOpen = expandedGroups.has(key);
|
||||
const state = groupFindings[key] ?? {
|
||||
items: [],
|
||||
total: group.count,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
};
|
||||
const selectableIds = state.items
|
||||
.map((finding) => finding.finding_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
|
||||
let groupChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
|
||||
groupChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
groupChecked = "indeterminate";
|
||||
}
|
||||
return (
|
||||
<Card key={key} className="gap-0 py-0">
|
||||
<CardHeader className="px-4 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
aria-expanded={groupOpen}
|
||||
onClick={() => toggleGroup(key)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
groupOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle className="truncate text-sm">
|
||||
{group.task_id === null
|
||||
? "未关联 / 任务已删除"
|
||||
: group.task_name
|
||||
? `${group.task_name}(任务 #${group.task_id})`
|
||||
: `任务 #${group.task_id}`}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate" title={group.task_description}>
|
||||
{group.task_description || "来源任务不可用"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{group.task_status && <StatusBadge domain="task" value={group.task_status} dot />}
|
||||
{SEVERITIES.map((level) => {
|
||||
const count = group[level];
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span key={level} className="inline-flex items-center gap-1">
|
||||
<StatusBadge domain="severity" value={level} dot />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
|
||||
{group.task_id !== null && (
|
||||
<Button size="icon-sm" variant="ghost" asChild>
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${group.task_id}`}
|
||||
aria-label={`查看任务 #${group.task_id}`}
|
||||
>
|
||||
<ArrowUpRightIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{groupOpen && (
|
||||
<CardContent className="px-0">
|
||||
{state.loading && !state.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
|
||||
>>>>>>> Stashed changes
|
||||
换行/内部滚动,不会把整张表撑出横向滚动条。 */}
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
|
||||
@@ -447,7 +447,7 @@ function FoldSheet({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
function GraphInner({ taskId }: { taskId: string }) {
|
||||
function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
|
||||
const [data, setData] = React.useState<{
|
||||
nodes: CoverageGraphNode[];
|
||||
edges: CoverageGraphEdge[];
|
||||
@@ -470,12 +470,17 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
setLoading(true);
|
||||
api
|
||||
.taskCoverageGraph(taskId)
|
||||
.then((g) => setData({ nodes: g.nodes ?? [], edges: g.edges ?? [] }))
|
||||
.then((g) => {
|
||||
// 资产覆盖度功能关闭(B1):仍出图(范围内资产/company 关联依旧可见),但抹平
|
||||
// tested 状态——不显示测试进度、不做已测高亮。
|
||||
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
|
||||
setData({ nodes, edges: g.edges ?? [] });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保留上一次数据 */
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId]);
|
||||
}, [taskId, coverageEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGraph();
|
||||
@@ -630,8 +635,14 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{total > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span> · 已测{" "}
|
||||
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
|
||||
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
{" "}
|
||||
· 已测{" "}
|
||||
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
|
||||
@@ -655,12 +666,16 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-neutral-400" /> 未测
|
||||
</span>
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-neutral-400" /> 未测
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
|
||||
</span>
|
||||
@@ -684,12 +699,12 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CoverageGraphTab({ taskId }: { taskId: string }) {
|
||||
export function CoverageGraphTab({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
|
||||
<GraphInner taskId={taskId} />
|
||||
<GraphInner taskId={taskId} coverageEnabled={coverageEnabled} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
@@ -38,6 +38,30 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
@@ -153,7 +177,7 @@ export function InterceptTab({ taskId }: Props) {
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -199,7 +223,7 @@ export function InterceptTab({ taskId }: Props) {
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
|
||||
@@ -5,10 +5,24 @@ import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
CoinsIcon,
|
||||
ListChecksIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
>>>>>>> Stashed changes
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
>>>>>>> Stashed changes
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
@@ -21,7 +35,55 @@ import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { api } from "@/lib/api";
|
||||
<<<<<<< Updated upstream
|
||||
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
|
||||
=======
|
||||
import type {
|
||||
Finding,
|
||||
ModelTokenStat,
|
||||
Stats,
|
||||
Task,
|
||||
TaskConstraint,
|
||||
TaskGoal,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
} from "@/lib/types";
|
||||
|
||||
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// 缓存命中率 = 缓存读 / 输入(InputTokens 已含 cache_read 子集,故比值在 0–100%)。
|
||||
function cacheHitRate(cacheRead: number, input: number): string {
|
||||
if (input <= 0) return "—";
|
||||
return Math.round((cacheRead / input) * 100) + "%";
|
||||
}
|
||||
|
||||
// 测试范围一条的显示值:域名 / 网段 / 公司。
|
||||
function scopeValue(row: TaskScopeRow): string {
|
||||
if (row.domain) return row.domain;
|
||||
if (row.net) return row.net;
|
||||
if (row.company_id) return row.company_name || `企业 #${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: "手动",
|
||||
};
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -55,6 +117,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [coverage, setCoverage] = React.useState<{
|
||||
enabled: boolean;
|
||||
scope_rows: number;
|
||||
denominator: number;
|
||||
tested: number;
|
||||
@@ -63,6 +126,203 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
} | null>(null);
|
||||
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
|
||||
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
// 测试范围列表 + 新增表单状态。
|
||||
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("");
|
||||
// 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
|
||||
const [modelTokens, setModelTokens] = React.useState<ModelTokenStat[]>([]);
|
||||
// 目标管理:目标列表 + 新增表单 + 行内编辑状态。
|
||||
const [goals, setGoals] = React.useState<TaskGoal[]>([]);
|
||||
const [goalText, setGoalText] = React.useState("");
|
||||
const [goalVuln, setGoalVuln] = React.useState("");
|
||||
const [goalBusy, setGoalBusy] = React.useState(false);
|
||||
const [goalErr, setGoalErr] = React.useState("");
|
||||
const [editingGoalId, setEditingGoalId] = React.useState<string | null>(null);
|
||||
const [editText, setEditText] = React.useState("");
|
||||
const [editVuln, setEditVuln] = React.useState("");
|
||||
// 约束管理:约束列表 + 新增表单 + 行内编辑状态。
|
||||
const [constraints, setConstraints] = React.useState<TaskConstraint[]>([]);
|
||||
const [conText, setConText] = React.useState("");
|
||||
const [conKind, setConKind] = React.useState<TaskConstraint["kind"]>("deny");
|
||||
const [conBusy, setConBusy] = React.useState(false);
|
||||
const [conErr, setConErr] = React.useState("");
|
||||
const [editingConId, setEditingConId] = React.useState<string | null>(null);
|
||||
const [editConText, setEditConText] = React.useState("");
|
||||
const [editConKind, setEditConKind] = React.useState<TaskConstraint["kind"]>("deny");
|
||||
|
||||
const loadTokens = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.tokensByModel(taskId);
|
||||
setModelTokens(resp.models);
|
||||
} catch {
|
||||
// 忽略:无 PG 时接口报错,卡片自然为空
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const loadScope = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.taskScope(taskId);
|
||||
setScope(resp.scope);
|
||||
} catch {
|
||||
// 忽略:无 asset store 时接口 503,范围卡片自然为空
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const loadGoals = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.taskGoals(taskId);
|
||||
setGoals(resp.goals);
|
||||
} catch {
|
||||
// 忽略:瞬时错误,下次轮询重试
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const addGoal = async () => {
|
||||
const text = goalText.trim();
|
||||
if (!text) return;
|
||||
setGoalBusy(true);
|
||||
setGoalErr("");
|
||||
try {
|
||||
await api.addGoal(taskId, text, goalVuln.trim() || undefined);
|
||||
setGoalText("");
|
||||
setGoalVuln("");
|
||||
await loadGoals();
|
||||
} catch (e) {
|
||||
setGoalErr(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setGoalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditGoal = (g: TaskGoal) => {
|
||||
setEditingGoalId(g.id);
|
||||
setEditText(g.text);
|
||||
setEditVuln(g.vulnclass ?? "");
|
||||
};
|
||||
|
||||
const cancelEditGoal = () => {
|
||||
setEditingGoalId(null);
|
||||
setEditText("");
|
||||
setEditVuln("");
|
||||
};
|
||||
|
||||
const saveEditGoal = async (g: TaskGoal) => {
|
||||
const text = editText.trim();
|
||||
if (!text) return;
|
||||
setGoalBusy(true);
|
||||
setGoalErr("");
|
||||
try {
|
||||
await api.updateGoal(taskId, g.id, text, editVuln.trim() || undefined);
|
||||
cancelEditGoal();
|
||||
await loadGoals();
|
||||
} catch (e) {
|
||||
setGoalErr(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setGoalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeGoal = async (g: TaskGoal) => {
|
||||
setGoals((prev) => prev.filter((x) => x.id !== g.id));
|
||||
try {
|
||||
await api.deleteGoal(taskId, g.id);
|
||||
} catch {
|
||||
await loadGoals(); // 删除失败:重新拉取还原
|
||||
}
|
||||
};
|
||||
|
||||
const loadConstraints = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.taskConstraints(taskId);
|
||||
setConstraints(resp.constraints);
|
||||
} catch {
|
||||
// 忽略:瞬时错误,下次轮询重试
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const addConstraint = async () => {
|
||||
const text = conText.trim();
|
||||
if (!text) return;
|
||||
setConBusy(true);
|
||||
setConErr("");
|
||||
try {
|
||||
await api.addConstraint(taskId, text, conKind);
|
||||
setConText("");
|
||||
await loadConstraints();
|
||||
} catch (e) {
|
||||
setConErr(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setConBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditConstraint = (c: TaskConstraint) => {
|
||||
setEditingConId(c.id);
|
||||
setEditConText(c.text);
|
||||
setEditConKind(c.kind);
|
||||
};
|
||||
|
||||
const cancelEditConstraint = () => {
|
||||
setEditingConId(null);
|
||||
setEditConText("");
|
||||
setEditConKind("deny");
|
||||
};
|
||||
|
||||
const saveEditConstraint = async (c: TaskConstraint) => {
|
||||
const text = editConText.trim();
|
||||
if (!text) return;
|
||||
setConBusy(true);
|
||||
setConErr("");
|
||||
try {
|
||||
await api.updateConstraint(taskId, c.id, text, editConKind);
|
||||
cancelEditConstraint();
|
||||
await loadConstraints();
|
||||
} catch (e) {
|
||||
setConErr(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setConBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeConstraint = async (c: TaskConstraint) => {
|
||||
setConstraints((prev) => prev.filter((x) => x.id !== c.id));
|
||||
try {
|
||||
await api.deleteConstraint(taskId, c.id);
|
||||
} catch {
|
||||
await loadConstraints(); // 删除失败:重新拉取还原
|
||||
}
|
||||
};
|
||||
|
||||
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(); // 删除失败:重新拉取还原
|
||||
}
|
||||
};
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
const markRerun = (key: string, on: boolean) =>
|
||||
setRerunning((prev) => {
|
||||
@@ -133,12 +393,29 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
|
||||
load();
|
||||
<<<<<<< Updated upstream
|
||||
const timer = setInterval(load, 3000);
|
||||
=======
|
||||
void loadScope();
|
||||
void loadTokens();
|
||||
void loadGoals();
|
||||
void loadConstraints();
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
void loadTokens();
|
||||
void loadGoals();
|
||||
void loadConstraints();
|
||||
}, 3000);
|
||||
>>>>>>> Stashed changes
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
<<<<<<< Updated upstream
|
||||
}, [taskId]);
|
||||
=======
|
||||
}, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]);
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
const running = intents.filter((i) => i.state === "running");
|
||||
const open = intents.filter((i) => i.state === "open");
|
||||
@@ -172,7 +449,252 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{coverage && coverage.scope_rows > 0 && (
|
||||
{/* 目标管理:查看/新增/修改/删除本任务的探索目标。新增与修改会通知规划者并复活任务,
|
||||
删除仅通知规划者(不复活)。目标 = 最终可交付/可核验的结果,不是攻击步骤或侦察动作。 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecksIcon className="size-4 text-primary" /> 目标管理
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(最终可核验的目标,共 {goals.length} 条;新增/修改会通知规划者并复活任务)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{/* 新增表单 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
placeholder="新增目标,如『拿到管理员账号的越权访问』"
|
||||
value={goalText}
|
||||
onChange={(e) => setGoalText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-32 text-sm"
|
||||
placeholder="漏洞类(可选)"
|
||||
value={goalVuln}
|
||||
onChange={(e) => setGoalVuln(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={goalBusy || !goalText.trim()} onClick={() => void addGoal()}>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{goalErr && <span className="text-xs text-red-500">{goalErr}</span>}
|
||||
</div>
|
||||
{/* 目标列表 */}
|
||||
{goals.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{goals.map((g) =>
|
||||
editingGoalId === g.id ? (
|
||||
<div key={g.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEditGoal(g);
|
||||
if (e.key === "Escape") cancelEditGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-32 text-sm"
|
||||
placeholder="漏洞类(可选)"
|
||||
value={editVuln}
|
||||
onChange={(e) => setEditVuln(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEditGoal(g);
|
||||
if (e.key === "Escape") cancelEditGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy || !editText.trim()}
|
||||
onClick={() => void saveEditGoal(g)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={cancelEditGoal}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={g.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<StatusBadge domain="goal" value={g.state} />
|
||||
<span className="min-w-0 flex-1 break-words">{g.text}</span>
|
||||
{g.vulnclass && (
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||
{g.vulnclass}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={() => startEditGoal(g)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={() => void removeGoal(g)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">暂无目标,添加后规划者会据此派发探索意图并判定达成。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 操作约束管理:allow=允许 / deny=禁止。约束会在下一轮规划时注入 planner/worker 的系统
|
||||
提示以框定探索边界(注入范围可在 系统设置 里按 planner/worker 开关)。改动不即时打断,
|
||||
下一轮规划自然读到。 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlertIcon className="size-4 text-amber-500" /> 操作约束
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(框定 planner/worker 的探索边界,共 {constraints.length} 条;改动下一轮规划生效)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{/* 新增表单 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
value={conKind}
|
||||
onChange={(e) => setConKind(e.target.value as TaskConstraint["kind"])}
|
||||
>
|
||||
<NativeSelectOption value="deny">禁止</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">允许</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
placeholder="一条操作约束,如『仅测当前端口,不扫其他端口』"
|
||||
value={conText}
|
||||
onChange={(e) => setConText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addConstraint();
|
||||
}}
|
||||
disabled={conBusy}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={conBusy || !conText.trim()} onClick={() => void addConstraint()}>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
|
||||
</div>
|
||||
{/* 约束列表 */}
|
||||
{constraints.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{constraints.map((c) =>
|
||||
editingConId === c.id ? (
|
||||
<div key={c.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
value={editConKind}
|
||||
onChange={(e) => setEditConKind(e.target.value as TaskConstraint["kind"])}
|
||||
>
|
||||
<NativeSelectOption value="deny">禁止</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">允许</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
value={editConText}
|
||||
onChange={(e) => setEditConText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEditConstraint(c);
|
||||
if (e.key === "Escape") cancelEditConstraint();
|
||||
}}
|
||||
disabled={conBusy}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={conBusy || !editConText.trim()}
|
||||
onClick={() => void saveEditConstraint(c)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={conBusy}
|
||||
onClick={cancelEditConstraint}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={c.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 text-xs ${
|
||||
c.kind === "allow"
|
||||
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/15 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{c.kind === "allow" ? "允许" : "禁止"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words">{c.text}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={conBusy}
|
||||
onClick={() => startEditConstraint(c)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={conBusy}
|
||||
onClick={() => void removeConstraint(c)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
暂无操作约束。建任务时会自动从描述/目标抽取;也可在此手动增删改,用来框定「允许/禁止做哪些操作」。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -24,7 +24,12 @@ import { cn } from "@/lib/utils";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
<<<<<<< Updated upstream
|
||||
import { Input } from "@/components/ui/input";
|
||||
=======
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
>>>>>>> Stashed changes
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -33,6 +38,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type {
|
||||
Activity,
|
||||
@@ -44,6 +50,13 @@ import type {
|
||||
TokenTotal,
|
||||
} from "@/lib/types";
|
||||
|
||||
// resolutionLabel names the LLM a session runs on. The badge shows this alone and
|
||||
// keeps the model id in its tooltip. Env-backed configs can arrive without a name,
|
||||
// so fall back to the model id rather than rendering an empty badge.
|
||||
function resolutionLabel(r: TaskLLMResolution): string {
|
||||
return r.name || r.model || "未命名配置";
|
||||
}
|
||||
|
||||
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
|
||||
function fmtBytes(n: number): string {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
@@ -233,6 +246,14 @@ function SessionItem({
|
||||
hasPending,
|
||||
unread,
|
||||
onClick,
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
onPause,
|
||||
onResume,
|
||||
onCancel,
|
||||
controlling,
|
||||
deleted,
|
||||
>>>>>>> Stashed changes
|
||||
}: {
|
||||
s: Session;
|
||||
active: boolean;
|
||||
@@ -240,13 +261,22 @@ function SessionItem({
|
||||
hasPending?: boolean;
|
||||
unread?: number;
|
||||
onClick: () => void;
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
onPause?: () => void;
|
||||
onResume?: () => void;
|
||||
onCancel?: () => void;
|
||||
controlling?: boolean;
|
||||
deleted?: boolean;
|
||||
>>>>>>> Stashed changes
|
||||
}) {
|
||||
const icon =
|
||||
s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
) : s.live ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
const icon = deleted ? (
|
||||
<Trash2Icon className="size-3.5 text-destructive" />
|
||||
) : s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
) : s.live ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -256,11 +286,91 @@ function SessionItem({
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<<<<<<< Updated upstream
|
||||
{icon ?? <span className="size-3.5 shrink-0" />}
|
||||
{s.intent_id && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
#{s.intent_id}
|
||||
</span>
|
||||
=======
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-sm"
|
||||
>
|
||||
{icon ?? <span className="size-3.5 shrink-0" />}
|
||||
{s.intent_id && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
#{s.intent_id}
|
||||
</span>
|
||||
)}
|
||||
{s.inherited && s.source_task_id && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
来源 #{s.source_task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1 truncate text-sm font-medium", deleted && "text-muted-foreground line-through")}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
{deleted && (
|
||||
<Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive">
|
||||
已删除
|
||||
</Badge>
|
||||
)}
|
||||
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
|
||||
{!active && unread ? (
|
||||
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
) : null}
|
||||
{s.live && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
|
||||
实时
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{controllable && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-1 opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover/session:opacity-100 sm:group-focus-within/session:opacity-100">
|
||||
{s.status === "running" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onPause}
|
||||
disabled={controlling}
|
||||
title="暂停 Worker"
|
||||
aria-label="暂停 Worker"
|
||||
>
|
||||
{controlling ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onResume}
|
||||
disabled={controlling}
|
||||
title="恢复 Worker"
|
||||
aria-label="恢复 Worker"
|
||||
>
|
||||
{controlling ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onCancel}
|
||||
disabled={controlling}
|
||||
title="删除该意图(需填写原因,保留数据)"
|
||||
aria-label="删除该意图(需填写原因,保留数据)"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
>>>>>>> Stashed changes
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
|
||||
{hasPending && (
|
||||
@@ -295,6 +405,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [cancelReason, setCancelReason] = React.useState("");
|
||||
>>>>>>> Stashed changes
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
@@ -313,6 +430,50 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
|
||||
const patchIntentState = React.useCallback((intentId: string, state?: string) => {
|
||||
const patch = (rows: TaskNode[]) =>
|
||||
state
|
||||
? rows.map((row) => (row.id === intentId ? { ...row, state } : row))
|
||||
: rows.filter((row) => row.id !== intentId);
|
||||
setIntents(patch);
|
||||
setOlderIntents(patch);
|
||||
}, []);
|
||||
|
||||
const controlWorker = React.useCallback(
|
||||
async (session: Session, action: "pause" | "resume" | "cancel", reason?: string) => {
|
||||
if (!session.intent_id || session.inherited || controllingIntent) return;
|
||||
if (action === "cancel" && !reason?.trim()) {
|
||||
toast.error("请填写删除原因");
|
||||
return;
|
||||
}
|
||||
setControllingIntent(session.intent_id);
|
||||
try {
|
||||
await api.controlIntent(taskId, session.intent_id, action, reason);
|
||||
if (action === "pause") {
|
||||
patchIntentState(session.intent_id, "paused");
|
||||
toast.success(`Worker #${session.intent_id} 已暂停`);
|
||||
} else if (action === "resume") {
|
||||
patchIntentState(session.intent_id, "open");
|
||||
toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
|
||||
} else {
|
||||
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。
|
||||
patchIntentState(session.intent_id, "stopped");
|
||||
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
|
||||
setCancelReason("");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Worker 操作失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setControllingIntent(null);
|
||||
setCancelIntent(null);
|
||||
}
|
||||
},
|
||||
[controllingIntent, patchIntentState, taskId],
|
||||
);
|
||||
>>>>>>> Stashed changes
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
const [sseLive, setSseLive] = React.useState(false);
|
||||
@@ -627,21 +788,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// For each worker session (intent), derive the display title from the intent
|
||||
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
|
||||
const sessionMeta = React.useMemo(() => {
|
||||
const map = new Map<string, { title: string; json: unknown }>();
|
||||
const map = new Map<string, { title: string; json: unknown; deleted: boolean; deleteReason: string }>();
|
||||
for (const node of allIntents) {
|
||||
let title = `Intent ${node.id}`;
|
||||
let parsedPayload: unknown = node.payload;
|
||||
let deleted = false;
|
||||
let deleteReason = "";
|
||||
if (node.payload) {
|
||||
try {
|
||||
const p = JSON.parse(node.payload);
|
||||
parsedPayload = p;
|
||||
if (p?.summary) title = String(p.summary);
|
||||
if (p?.cancelled_by_user) {
|
||||
deleted = true;
|
||||
deleteReason = String(p.cancel_reason ?? "");
|
||||
}
|
||||
} catch {
|
||||
title = node.payload.trim() || title;
|
||||
}
|
||||
}
|
||||
const json = { ...node, payload: parsedPayload };
|
||||
map.set(node.id, { title, json });
|
||||
map.set(node.id, { title, json, deleted, deleteReason });
|
||||
}
|
||||
return map;
|
||||
}, [allIntents]);
|
||||
@@ -861,6 +1028,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
|
||||
const mainLoaded = !!store.main?.loaded;
|
||||
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
|
||||
const sendMode = useChatSendMode();
|
||||
// What the transcript pane should show for the active session.
|
||||
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
|
||||
|
||||
@@ -894,6 +1063,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<<<<<<< Updated upstream
|
||||
{taskTokens && (
|
||||
<div
|
||||
className="mt-1 text-[11px] tabular-nums text-muted-foreground"
|
||||
@@ -901,6 +1071,62 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
>
|
||||
任务总计 · input {fmtTokens(taskTokens.input_tokens)} · cache{" "}
|
||||
{fmtTokens(taskTokens.cache_read_tokens)} · output {fmtTokens(taskTokens.output_tokens)}
|
||||
=======
|
||||
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="flex w-full flex-col gap-3 p-2">
|
||||
{(["mainagent", "planner", "system", "worker"] as const).map((role) => {
|
||||
const items = grouped[role];
|
||||
if (!items.length) return null;
|
||||
const Meta = roleMeta[role];
|
||||
return (
|
||||
<div key={role} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
<Meta.icon className="size-3.5" />
|
||||
{Meta.label}
|
||||
</div>
|
||||
{items.map((s) => {
|
||||
const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined;
|
||||
return (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
s={s}
|
||||
active={s.id === activeId}
|
||||
displayTitle={meta?.title ?? s.title}
|
||||
hasPending={hasPendingForSession(s)}
|
||||
unread={store[keyForSession(s)]?.unread}
|
||||
onClick={() => setActiveId(s.id)}
|
||||
controlling={controllingIntent === s.intent_id}
|
||||
deleted={meta?.deleted}
|
||||
onPause={() => void controlWorker(s, "pause")}
|
||||
onResume={() => void controlWorker(s, "resume")}
|
||||
onCancel={() => {
|
||||
setCancelIntent(s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{role === "worker" && intentsHasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOlderIntents}
|
||||
disabled={loadingOlderIntents}
|
||||
className="mt-0.5 flex items-center justify-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/50"
|
||||
>
|
||||
{loadingOlderIntents ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCwIcon className="size-3.5" />
|
||||
)}
|
||||
加载更早的 Worker
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mainLoaded && !workerSessions.length && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">暂无运行中的 Worker 会话。</div>
|
||||
)}
|
||||
>>>>>>> Stashed changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -968,11 +1194,31 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const titleEl = <span className="text-sm font-medium">{title}</span>;
|
||||
return meta?.json ? (
|
||||
<Tooltip>
|
||||
<<<<<<< Updated upstream
|
||||
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
|
||||
<pre className="p-2 text-[10px] leading-relaxed">
|
||||
{JSON.stringify(meta.json, null, 2)}
|
||||
</pre>
|
||||
=======
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-28 shrink-0 font-normal"
|
||||
aria-label={
|
||||
activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用"
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
|
||||
{activeResolution.available
|
||||
? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ")
|
||||
: activeResolution.reason || "没有可用的 LLM 配置"}
|
||||
>>>>>>> Stashed changes
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : titleEl;
|
||||
@@ -1005,8 +1251,33 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
)}
|
||||
{isMain && <span>可交互</span>}
|
||||
</div>
|
||||
<<<<<<< Updated upstream
|
||||
</div>
|
||||
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
|
||||
=======
|
||||
{(() => {
|
||||
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
|
||||
if (!dm?.deleted) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs">
|
||||
<Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-destructive">此意图已被用户删除</span>
|
||||
<span className="text-muted-foreground">
|
||||
(已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史)
|
||||
</span>
|
||||
{dm.deleteReason && (
|
||||
<p className="mt-1 break-words text-foreground">
|
||||
<span className="text-muted-foreground">删除原因:</span>
|
||||
{dm.deleteReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
|
||||
>>>>>>> Stashed changes
|
||||
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
|
||||
out the width and defeat the truncation below — the transcript wraps to
|
||||
the panel instead of overflowing horizontally. */}
|
||||
@@ -1078,6 +1349,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
className="hidden"
|
||||
onChange={(e) => void pickFiles(e.target.files)}
|
||||
/>
|
||||
<<<<<<< Updated upstream
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -1115,6 +1387,113 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
=======
|
||||
<InputGroup className="min-h-9 has-disabled:opacity-100">
|
||||
<InputGroupTextarea
|
||||
rows={1}
|
||||
aria-label="给主 Agent 发消息"
|
||||
placeholder="给主 Agent 发消息,引导探索方向…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (!shouldSubmitOnKey(e, sendMode)) return;
|
||||
e.preventDefault();
|
||||
if (!mainLive) send();
|
||||
}}
|
||||
disabled={mainLive}
|
||||
className="max-h-36 min-h-9 overflow-y-auto"
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={mainLive || uploading}
|
||||
title="上传文件"
|
||||
aria-label="上传文件"
|
||||
>
|
||||
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
|
||||
</InputGroupButton>
|
||||
{mainLive ? (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
size="icon-xs"
|
||||
variant="destructive"
|
||||
onClick={stop}
|
||||
disabled={stopping}
|
||||
title="停止当前执行"
|
||||
aria-label="停止当前执行"
|
||||
>
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
size="icon-xs"
|
||||
variant="default"
|
||||
onClick={send}
|
||||
disabled={(!input.trim() && attachments.length === 0) || sending}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center border-t px-4 py-2">
|
||||
<TodoPopover
|
||||
seq={latestTodoSeq}
|
||||
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={cancelIntent !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCancelIntent(null);
|
||||
setCancelReason("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words whitespace-normal">
|
||||
删除会<strong>停止该意图</strong>(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="grid gap-2 py-1">
|
||||
<label htmlFor="cancel-reason" className="text-sm font-medium">
|
||||
删除原因(必填)
|
||||
</label>
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>返回</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()}
|
||||
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason)}
|
||||
>
|
||||
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
>>>>>>> Stashed changes
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -125,8 +125,13 @@ function TaskDetailInner() {
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<<<<<<< Updated upstream
|
||||
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
|
||||
{task.description}
|
||||
=======
|
||||
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
|
||||
{task.name || task.description}
|
||||
>>>>>>> Stashed changes
|
||||
</h1>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
{task.id}
|
||||
@@ -182,7 +187,7 @@ function TaskDetailInner() {
|
||||
<AssetsTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="coverage" className="mt-0">
|
||||
<CoverageGraphTab taskId={id} />
|
||||
<CoverageGraphTab taskId={id} coverageEnabled={task?.coverage_enabled !== false} />
|
||||
</TabsContent>
|
||||
<TabsContent value="intercept" className="mt-0">
|
||||
<InterceptTab taskId={id} />
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SlidersHorizontalIcon,
|
||||
StarIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
>>>>>>> Stashed changes
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
PlusIcon,
|
||||
@@ -150,6 +168,17 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
|
||||
{ value: "timeout", label: "已超时" },
|
||||
];
|
||||
|
||||
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
|
||||
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
|
||||
// 两边不会出现一个可点、另一个不可点的分歧。
|
||||
const PAUSABLE_STATUSES = new Set<TaskStatus>(["created", "queued", "running"]);
|
||||
|
||||
function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
|
||||
if (status === "paused") return "resume";
|
||||
if (PAUSABLE_STATUSES.has(status)) return "pause";
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -164,6 +193,10 @@ export default function TasksPage() {
|
||||
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
(t.name ?? "").toLowerCase().includes(q) ||
|
||||
>>>>>>> Stashed changes
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.goal.toLowerCase().includes(q) ||
|
||||
t.id.toLowerCase().includes(q)
|
||||
@@ -214,6 +247,7 @@ export default function TasksPage() {
|
||||
return () => clearInterval(i);
|
||||
}, [hasRunning]);
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
const deleteTask = React.useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.deleteTask(id);
|
||||
@@ -223,6 +257,152 @@ export default function TasksPage() {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
}, [load]);
|
||||
=======
|
||||
const deleteTask = React.useCallback(
|
||||
async (id: string, options: DeleteTaskOptions) => {
|
||||
try {
|
||||
const result = await api.deleteTask(id, options);
|
||||
if (result.cleanup_warning) {
|
||||
toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`);
|
||||
} else {
|
||||
toast.success(deleteSummary(result));
|
||||
}
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
// controlTask 是行内暂停/继续:批量走 controlTasksBatch,单行走单任务接口,省去
|
||||
// 「先勾选再点批量」。清空 lastRef 让下一次轮询即使负载相同也照单接收,否则状态
|
||||
// 回写会被去重挡掉、按钮看起来没反应。
|
||||
const controlTask = React.useCallback(
|
||||
async (id: string, action: "pause" | "resume") => {
|
||||
try {
|
||||
const result = await api.controlTask(id, action);
|
||||
toast.success(
|
||||
action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
// 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。
|
||||
lastRef.current = "";
|
||||
load();
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
|
||||
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
|
||||
const deleteTasks = React.useCallback(
|
||||
async (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => {
|
||||
const total: DeleteCounts = {
|
||||
assets_deleted: 0,
|
||||
assets_detached: 0,
|
||||
traffic_deleted: 0,
|
||||
files_deleted: false,
|
||||
findings_deleted: 0,
|
||||
llm_records_deleted: 0,
|
||||
};
|
||||
const deleted: string[] = [];
|
||||
const failed: { id: string; message: string }[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const r = await api.deleteTask(id, options);
|
||||
total.assets_deleted += r.assets_deleted;
|
||||
total.assets_detached += r.assets_detached;
|
||||
total.traffic_deleted += r.traffic_deleted;
|
||||
total.files_deleted = total.files_deleted || r.files_deleted;
|
||||
total.findings_deleted += r.findings_deleted;
|
||||
total.llm_records_deleted += r.llm_records_deleted;
|
||||
if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`);
|
||||
deleted.push(id);
|
||||
} catch (e) {
|
||||
failed.push({ id, message: (e as Error).message });
|
||||
}
|
||||
onProgress(deleted.length + failed.length);
|
||||
}
|
||||
|
||||
if (deleted.length > 0) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of deleted) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const details = deleteDetails(total);
|
||||
const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : "");
|
||||
if (warnings.length > 0) {
|
||||
toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`);
|
||||
} else {
|
||||
toast.success(summary);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const head = failed
|
||||
.slice(0, 3)
|
||||
.map((f) => `#${f.id}(${f.message})`)
|
||||
.join(";");
|
||||
toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
load();
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]);
|
||||
const pausableTaskIDs = React.useMemo(
|
||||
() => selectedTasks.filter((task) => taskControlAction(task.status) === "pause").map((task) => task.id),
|
||||
[selectedTasks],
|
||||
);
|
||||
const resumableTaskIDs = React.useMemo(
|
||||
() => selectedTasks.filter((task) => taskControlAction(task.status) === "resume").map((task) => task.id),
|
||||
[selectedTasks],
|
||||
);
|
||||
|
||||
const controlSelectedTasks = React.useCallback(
|
||||
async (action: "pause" | "resume", ids: string[]) => {
|
||||
if (ids.length === 0 || batchControlling) return;
|
||||
if (ids.length > 100) {
|
||||
toast.error("一次最多控制 100 个任务");
|
||||
return;
|
||||
}
|
||||
setBatchControlling(action);
|
||||
try {
|
||||
const result = await api.controlTasksBatch(ids, action);
|
||||
const succeeded = result.items.filter((item) => item.ok);
|
||||
const failed = result.items.filter((item) => !item.ok);
|
||||
if (succeeded.length > 0) {
|
||||
toast.success(
|
||||
action === "pause"
|
||||
? `已暂停 ${succeeded.length} 个任务`
|
||||
: `已继续 ${succeeded.length} 个任务${succeeded.some((item) => item.queued) ? ",部分任务已进入队列" : ""}`,
|
||||
);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((item) => `#${item.id}(${item.error || "状态已变化"})`)
|
||||
.join(";");
|
||||
toast.error(`${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
lastRef.current = "";
|
||||
load();
|
||||
} catch (error) {
|
||||
toast.error(`${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setBatchControlling(null);
|
||||
}
|
||||
},
|
||||
[batchControlling, load],
|
||||
);
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -279,6 +459,7 @@ export default function TasksPage() {
|
||||
<TableHeader className="[&_tr]:border-t">
|
||||
<TableRow>
|
||||
<TableHead className="font-mono">ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
@@ -298,6 +479,12 @@ export default function TasksPage() {
|
||||
// 带来的整表重渲染,只让在跑的那几行走时长。
|
||||
nowSec={task.status === "running" ? nowSec : 0}
|
||||
onDelete={deleteTask}
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
onControl={controlTask}
|
||||
selected={selectedIds.has(task.id)}
|
||||
onSelectedChange={toggleSelected}
|
||||
>>>>>>> Stashed changes
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -323,10 +510,23 @@ const TaskRow = React.memo(function TaskRow({
|
||||
task,
|
||||
nowSec,
|
||||
onDelete,
|
||||
<<<<<<< Updated upstream
|
||||
}: {
|
||||
task: Task;
|
||||
nowSec: number;
|
||||
onDelete: (id: string) => void;
|
||||
=======
|
||||
onControl,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
}: {
|
||||
task: Task;
|
||||
nowSec: number;
|
||||
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
|
||||
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
|
||||
selected: boolean;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
>>>>>>> Stashed changes
|
||||
}) {
|
||||
return (
|
||||
<TableRow className="group border-border/60">
|
||||
@@ -335,14 +535,34 @@ const TaskRow = React.memo(function TaskRow({
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex max-w-xs items-center gap-2">
|
||||
<<<<<<< Updated upstream
|
||||
<span className="truncate" title={task.description}>
|
||||
{task.description}
|
||||
</span>
|
||||
{task.active && (
|
||||
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
=======
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
|
||||
className="min-w-0 truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={task.name || task.description}
|
||||
>
|
||||
{task.name || <span className="text-muted-foreground">未命名</span>}
|
||||
</Link>
|
||||
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
|
||||
>>>>>>> Stashed changes
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-xs">
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
|
||||
className="block truncate rounded-sm hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={task.description}
|
||||
>
|
||||
{task.description}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="task" value={task.status} dot />
|
||||
@@ -387,6 +607,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
|
||||
<<<<<<< Updated upstream
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/function/tasks/detail?id=${task.id}`}>
|
||||
@@ -417,18 +638,349 @@ const TaskRow = React.memo(function TaskRow({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
=======
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button size="icon" variant="ghost" asChild aria-label="查看任务详情" title="查看任务详情">
|
||||
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}>
|
||||
<EyeIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<TaskControlButton task={task} onControl={onControl} />
|
||||
<DeleteTaskDialog task={task} onDelete={onDelete} />
|
||||
>>>>>>> Stashed changes
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
|
||||
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
|
||||
if (pending) return <Loader2Icon className="animate-spin" />;
|
||||
if (action === "resume") return <PlayIcon />;
|
||||
return <PauseIcon />;
|
||||
}
|
||||
|
||||
// TaskControlButton 是行内的暂停/继续开关。终态任务渲染为 disabled 而不是隐藏,
|
||||
// 这样各行操作列宽度一致,按钮位置不会随状态跳动。
|
||||
function TaskControlButton({
|
||||
task,
|
||||
onControl,
|
||||
}: {
|
||||
task: Task;
|
||||
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
|
||||
}) {
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const action = taskControlAction(task.status);
|
||||
const label = action === "resume" ? "继续任务" : "暂停任务";
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
title={action ? label : "该状态不可暂停或继续"}
|
||||
disabled={!action || pending}
|
||||
onClick={async () => {
|
||||
if (!action) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await onControl(task.id, action);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{taskControlIcon(pending, action)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyDeleteOptions = (): DeleteTaskOptions => ({
|
||||
delete_assets: false,
|
||||
delete_traffic: false,
|
||||
delete_files: false,
|
||||
delete_findings: false,
|
||||
delete_llm_records: false,
|
||||
});
|
||||
|
||||
const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
|
||||
"delete_assets",
|
||||
"delete_traffic",
|
||||
"delete_files",
|
||||
"delete_findings",
|
||||
"delete_llm_records",
|
||||
];
|
||||
|
||||
// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the
|
||||
// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when
|
||||
// several dialogs live in the same table.
|
||||
function DeleteOptionFields({
|
||||
idPrefix,
|
||||
options,
|
||||
onOptionsChange,
|
||||
disabled,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
options: DeleteTaskOptions;
|
||||
onOptionsChange: React.Dispatch<React.SetStateAction<DeleteTaskOptions>>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
|
||||
let allChecked: boolean | "indeterminate" = false;
|
||||
if (selectedCount === deleteOptionKeys.length) {
|
||||
allChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
allChecked = "indeterminate";
|
||||
}
|
||||
|
||||
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
|
||||
onOptionsChange((current) => ({ ...current, [key]: checked }));
|
||||
};
|
||||
|
||||
const updateAllOptions = (checked: boolean) => {
|
||||
onOptionsChange({
|
||||
delete_assets: checked,
|
||||
delete_traffic: checked,
|
||||
delete_files: checked,
|
||||
delete_findings: checked,
|
||||
delete_llm_records: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldSet disabled={disabled}>
|
||||
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-all-${idPrefix}`}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => updateAllOptions(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
|
||||
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-assets-${idPrefix}`}
|
||||
checked={options.delete_assets}
|
||||
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
|
||||
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-traffic-${idPrefix}`}
|
||||
checked={options.delete_traffic}
|
||||
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
|
||||
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-files-${idPrefix}`}
|
||||
checked={options.delete_files}
|
||||
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
|
||||
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-findings-${idPrefix}`}
|
||||
checked={options.delete_findings}
|
||||
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
|
||||
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-llm-records-${idPrefix}`}
|
||||
checked={options.delete_llm_records}
|
||||
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
|
||||
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTaskDialog({
|
||||
task,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (deleting) return;
|
||||
setOpen(next);
|
||||
if (next) setOptions(emptyDeleteOptions());
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(task.id, options);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="outline" aria-label="删除任务">
|
||||
<Trash2Icon className="text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words">
|
||||
{task.description ? (
|
||||
<>
|
||||
「
|
||||
<span className="break-all">
|
||||
{task.description.length > 80 ? `${task.description.slice(0, 80)}…` : task.description}
|
||||
</span>
|
||||
」
|
||||
</>
|
||||
) : (
|
||||
"该任务"
|
||||
)}
|
||||
的执行记录与探索链路将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<DeleteOptionFields idPrefix={task.id} options={options} onOptionsChange={setOptions} disabled={deleting} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? "删除中" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
|
||||
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
|
||||
// and the button shows live progress.
|
||||
function BulkDeleteTasksDialog({
|
||||
ids,
|
||||
onDelete,
|
||||
}: {
|
||||
ids: string[];
|
||||
onDelete: (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [done, setDone] = React.useState(0);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (deleting) return;
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
setOptions(emptyDeleteOptions());
|
||||
setDone(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
setDone(0);
|
||||
try {
|
||||
await onDelete(ids, options, setDone);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
|
||||
{ids.slice(0, 30).map((id) => (
|
||||
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">
|
||||
#{id}
|
||||
</code>
|
||||
))}
|
||||
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
|
||||
</div>
|
||||
<DeleteOptionFields idPrefix="bulk" options={options} onOptionsChange={setOptions} disabled={deleting} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
|
||||
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
|
||||
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
|
||||
// input lag. Now typing only re-renders the drawer.
|
||||
function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [goal, setGoal] = React.useState("");
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
@@ -436,6 +988,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
|
||||
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
|
||||
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
|
||||
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
|
||||
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
|
||||
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [uploadCount, setUploadCount] = React.useState(0);
|
||||
@@ -479,14 +1032,31 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
|
||||
const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
|
||||
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
|
||||
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
||||
<<<<<<< Updated upstream
|
||||
await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec);
|
||||
=======
|
||||
await api.createTask({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
goal: goal.trim(),
|
||||
llmProfileIds: llmProfileIDs.map(Number),
|
||||
sourceTaskIds: sourceTaskIDs,
|
||||
companyIds: companyIDs,
|
||||
timeoutSeconds: timeoutSec,
|
||||
seedFirstIntent,
|
||||
planHeartbeatSeconds: heartbeatSec,
|
||||
coverageEnabled,
|
||||
});
|
||||
>>>>>>> Stashed changes
|
||||
toast.success("任务已创建");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setGoal("");
|
||||
setLlmProfile(ACTIVE_PROFILE);
|
||||
setTimeoutMin("");
|
||||
setHeartbeatMin("10");
|
||||
setSeedFirstIntent(false);
|
||||
setCoverageEnabled(true);
|
||||
setUploadCount(0);
|
||||
draftIdRef.current = "";
|
||||
setOpen(false);
|
||||
@@ -497,10 +1067,216 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<<<<<<< Updated upstream
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm" className="ml-auto">
|
||||
<PlusIcon /> 新建任务
|
||||
=======
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm">
|
||||
<PlusIcon /> 新建任务
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
|
||||
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
|
||||
<SheetContent
|
||||
ref={sheetContentRef}
|
||||
side="right"
|
||||
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
|
||||
>
|
||||
<SheetHeader className="border-b p-6">
|
||||
<SheetTitle>新建任务</SheetTitle>
|
||||
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid gap-5">
|
||||
<TaskTemplateControls
|
||||
description={description}
|
||||
goal={goal}
|
||||
selectedTemplateID={selectedTemplateID}
|
||||
onSelectedTemplateIDChange={setSelectedTemplateID}
|
||||
onApply={(template) => {
|
||||
setDescription(template.description);
|
||||
setGoal(template.goal);
|
||||
setUploadCount(0);
|
||||
}}
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称(可选)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="给任务起个便于识别的名字,例如:Acme 官网渗透"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
className="min-h-32"
|
||||
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => void pickFiles(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
|
||||
上传文件
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{uploadCount > 0
|
||||
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
|
||||
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="goal">目标</Label>
|
||||
<Textarea
|
||||
id="goal"
|
||||
className="min-h-32"
|
||||
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
|
||||
<SourceTaskPicker
|
||||
tasks={tasks}
|
||||
value={sourceTaskIDs}
|
||||
onValueChange={setSourceTaskIDs}
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
<FieldDescription>
|
||||
最多关联 {MAX_SOURCE_TASKS}{" "}
|
||||
个任务。实时只读继承所选任务的持久化黑板、资产范围及相关流量;新任务写入独立黑板。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-companies">关联企业资产范围</FieldLabel>
|
||||
<CompanyPicker
|
||||
companies={companies}
|
||||
value={companyIDs}
|
||||
onValueChange={setCompanyIDs}
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
<FieldDescription>
|
||||
将所选企业的域名、IP、CIDR、ICP 和企业关键词作为 Agent
|
||||
可选范围上下文;不会自动生成意图或强制改变执行目标。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
|
||||
<TaskLLMProfileChain
|
||||
profiles={profiles}
|
||||
value={llmProfileIDs}
|
||||
onValueChange={setLLMProfileIDs}
|
||||
inputId="llm-profiles"
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
<FieldDescription>按列表顺序故障转移;第一项为当前配置,仅在明确额度不足时切换下一项。</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
|
||||
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||
高级设置
|
||||
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · 首个意图</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="grid gap-5 pt-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
|
||||
<Input
|
||||
id="timeout-min"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-40"
|
||||
placeholder="留空 = 不限时"
|
||||
value={timeoutMin}
|
||||
onChange={(e) => setTimeoutMin(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
|
||||
<Input
|
||||
id="heartbeat-min"
|
||||
type="number"
|
||||
min={10}
|
||||
className="w-40"
|
||||
placeholder="默认 10"
|
||||
value={heartbeatMin}
|
||||
onChange={(e) => setHeartbeatMin(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的
|
||||
worker)。下限 10 分钟。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
id="seed-first-intent"
|
||||
checked={seedFirstIntent}
|
||||
onCheckedChange={(v) => setSeedFirstIntent(!!v)}
|
||||
/>
|
||||
直接下发首个意图(描述+目标)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner
|
||||
接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="coverage-enabled" className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
id="coverage-enabled"
|
||||
checked={coverageEnabled}
|
||||
onCheckedChange={(v) => setCoverageEnabled(!!v)}
|
||||
/>
|
||||
资产覆盖度功能
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
默认开启:计算并展示测试覆盖度、态势图显示测试进度、自动累积测试范围。关闭后不再计算/展示覆盖度,
|
||||
态势图仅展示资产不显示进度,agent 也不再获得范围类工具。关闭不影响「关联企业资产范围」。
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</SheetClose>
|
||||
<Button onClick={createTask} disabled={creating || uploading}>
|
||||
{creating && <Spinner data-icon="inline-start" />}
|
||||
{creating ? "创建中" : "创建"}
|
||||
>>>>>>> Stashed changes
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { BotIcon, CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
@@ -40,6 +40,33 @@ function SourceCell({ row }: { row: InterceptApprovalRow }) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
// MatchCell shows the matched rule name, or a "模型判定" badge when the decision
|
||||
// came from the LLM fallback judge (rule_id null). The reason (rule message or
|
||||
// model rationale, minus the [模型] prefix) is shown beneath.
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-sm">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
@@ -155,11 +182,7 @@ export default function ApprovalsPage() {
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell>
|
||||
{row.rule_name
|
||||
? <span className="text-sm">{row.rule_name}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -221,11 +244,7 @@ export default function ApprovalsPage() {
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell>
|
||||
{row.rule_name
|
||||
? <span className="text-sm">{row.rule_name}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BotIcon,
|
||||
ListFilterIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
@@ -48,8 +49,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptRule, InterceptAction, Tool } from "@/lib/types";
|
||||
import type { InterceptRule, InterceptAction, JudgeConfig, LLMProfile, Tool } from "@/lib/types";
|
||||
|
||||
// ---- tool scope ----
|
||||
|
||||
@@ -143,6 +145,203 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LLM fallback judge card ----
|
||||
|
||||
const FOLLOW_ACTIVE = "0"; // profile_id 0 = 跟随激活/默认配置
|
||||
|
||||
const defaultJudge = (): JudgeConfig => ({
|
||||
enabled: false,
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
});
|
||||
|
||||
function JudgeCard() {
|
||||
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [j, ps] = await Promise.all([api.interceptGetJudgeConfig(), api.llmProfiles()]);
|
||||
setCfg(j);
|
||||
setProfiles(ps);
|
||||
} catch (e) {
|
||||
toast.error("加载模型兜底配置失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function patch(p: Partial<JudgeConfig>) {
|
||||
setCfg((c) => ({ ...c, ...p }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig(cfg);
|
||||
toast.success("模型兜底配置已保存");
|
||||
await load(); // 回读:提示词若清空则回填内置模板
|
||||
} catch (e) {
|
||||
toast.error("保存失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePrompt() {
|
||||
// 清空提示词并保存 → 服务端下次返回内置模板全文,回填到输入框。
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig({ ...cfg, prompt: "" });
|
||||
const j = await api.interceptGetJudgeConfig();
|
||||
setCfg(j);
|
||||
toast.success("已恢复内置默认模板");
|
||||
} catch (e) {
|
||||
toast.error("恢复失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 启用开关 —— 独立高亮条 */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||
cfg.enabled ? "border-violet-400/50 bg-violet-50/40 dark:bg-violet-950/20" : "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BotIcon className={`h-5 w-5 shrink-0 ${cfg.enabled ? "text-violet-600" : "text-muted-foreground"}`} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">模型兜底审批</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
在<span className="font-medium text-foreground">拦截范围</span>内、且<span className="font-medium text-foreground">没有任何拦截规则命中</span>的命令,才由模型做语义判断(放行 / 转人工 / 拦截)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{cfg.enabled ? "已启用" : "未启用"}</span>
|
||||
<Switch checked={cfg.enabled} disabled={loading} onCheckedChange={(v) => patch({ enabled: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cfg.enabled && (
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* 左:提示词编辑器(直接展开,主区域) */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="flex h-full flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">审批提示词</p>
|
||||
<p className="text-xs text-muted-foreground">模型据此判定 ALLOW / ASK / DENY,可直接编辑</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
|
||||
恢复默认模板
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
|
||||
value={cfg.prompt}
|
||||
onChange={(e) => patch({ prompt: e.target.value })}
|
||||
placeholder="留空使用内置模板"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-right text-[11px] text-muted-foreground">{cfg.prompt.length} 字</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右:判定参数(设置栏) */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">判定模型与策略</p>
|
||||
<Field label="审批模型">
|
||||
<Select value={String(cfg.profile_id || 0)} onValueChange={(v) => patch({ profile_id: Number(v) })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FOLLOW_ACTIVE}>跟随激活配置</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型判定超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={cfg.timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模型失败时(出错 / 超时 / 无法解析)">
|
||||
<Select value={cfg.fail_action} onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">放行</SelectItem>
|
||||
<SelectItem value="ask">转人工审批</SelectItem>
|
||||
<SelectItem value="deny">拦截</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">人工审批(模型判为「转人工」时)</p>
|
||||
<Field label="审批等待超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
value={cfg.ask_timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ ask_timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="超时后默认动作">
|
||||
<Select value={cfg.ask_timeout_action} onValueChange={(v) => patch({ ask_timeout_action: v as JudgeConfig["ask_timeout_action"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">拦截</SelectItem>
|
||||
<SelectItem value="allow">放行</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={save} disabled={saving || loading}>
|
||||
{saving ? "保存中…" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function InterceptPage() {
|
||||
@@ -161,9 +360,19 @@ export default function InterceptPage() {
|
||||
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
|
||||
const [scopeLoading, setScopeLoading] = React.useState(false);
|
||||
const [scopeSaving, setScopeSaving] = React.useState(false);
|
||||
const [scopeTools, setScopeTools] = React.useState<string[]>([]); // 页头信息条:当前进入拦截的工具
|
||||
|
||||
// ---- data ----
|
||||
|
||||
const loadScope = React.useCallback(async () => {
|
||||
try {
|
||||
const cfg = await api.interceptGetToolConfig();
|
||||
setScopeTools(cfg.enabled_tools);
|
||||
} catch {
|
||||
// 信息条非关键,失败静默
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await api.interceptRules();
|
||||
@@ -175,7 +384,7 @@ export default function InterceptPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
React.useEffect(() => { load(); loadScope(); }, [load, loadScope]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (form.match_type !== "regex" || !form.pattern) { setRegexErr(""); setRegexWarn(false); return; }
|
||||
@@ -285,6 +494,7 @@ export default function InterceptPage() {
|
||||
try {
|
||||
await api.interceptSetToolConfig([...enabledTools]);
|
||||
toast.success("拦截范围已保存");
|
||||
setScopeTools([...enabledTools]);
|
||||
setScopeOpen(false);
|
||||
} catch (e) {
|
||||
toast.error("保存失败: " + (e as Error).message);
|
||||
@@ -298,34 +508,74 @@ export default function InterceptPage() {
|
||||
// ---- render ----
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-6">
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
|
||||
{/* ---- header ---- */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlertIcon className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">拦截规则</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
工具执行前按优先级(数字越大越先)逐条匹配,首条命中的规则生效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={openScope}>
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
拦截范围
|
||||
</Button>
|
||||
<Button onClick={openNew} size="sm">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlertIcon className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">命令拦截</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
工具执行前先按拦截规则匹配;未命中的命令可交由模型兜底判定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- rules table ---- */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* ---- 拦截范围信息条(规则匹配与模型兜底共用:不在范围内的工具两者都不介入)---- */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-2.5 ${
|
||||
scopeTools.length === 0
|
||||
? "border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20"
|
||||
: "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ListFilterIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 font-medium">拦截范围</span>
|
||||
{scopeTools.length === 0 ? (
|
||||
<span className="text-amber-700 dark:text-amber-500">
|
||||
未启用任何工具 — 拦截规则与模型兜底均不会生效
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="shrink-0">{scopeTools.length} 个工具</Badge>
|
||||
<span className="truncate text-muted-foreground" title={scopeTools.join("、")}>
|
||||
{scopeTools.join("、")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={scopeTools.length === 0 ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={openScope}
|
||||
>
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
调整范围
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="rules" className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">拦截规则</TabsTrigger>
|
||||
<TabsTrigger value="judge">模型配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ---- tab: 拦截规则 ---- */}
|
||||
<TabsContent value="rules" className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
按优先级(数字越大越先)逐条匹配,首条命中的规则生效
|
||||
</p>
|
||||
<Button onClick={openNew} size="sm" className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
|
||||
) : rules.length === 0 ? (
|
||||
@@ -404,8 +654,15 @@ export default function InterceptPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- tab: 模型配置 ---- */}
|
||||
<TabsContent value="judge" className="mt-4">
|
||||
<JudgeCard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ---- editor sheet ---- */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -461,7 +461,305 @@ export default function LLMPage() {
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
async function activate(id: string) {
|
||||
=======
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
||||
// 表单填到一半时点外面不该丢内容(Esc / ✕ 仍可关闭)。
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{isNew ? "新建模型配置" : `编辑:${profile?.name}`}
|
||||
{profile?.is_default && (
|
||||
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
|
||||
激活中
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isNew
|
||||
? "新建后不会自动激活,请在卡片上「设为激活」以启用。"
|
||||
: "修改后点击保存;激活配置保存后对全部 Agent 立即生效。"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-name">名称</Label>
|
||||
<Input
|
||||
id="p-name"
|
||||
placeholder="例如:OpenAI 生产"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>格式</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-model">模型</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="p-model"
|
||||
className="font-mono"
|
||||
placeholder="claude-opus-4-8"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
{/* modal: 这个 Popover 的内容被 portal 到 <body>,在 Sheet 的滚动锁之外,
|
||||
不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */}
|
||||
<Popover open={modelsOpen} onOpenChange={setModelsOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={loadingModels}
|
||||
onClick={loadModels}
|
||||
title="从 API 加载可用模型"
|
||||
>
|
||||
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{models.length > 0 && (
|
||||
<PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setModel(m);
|
||||
setModelsOpen(false);
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-base-url">Base URL(可选)</Label>
|
||||
<Input
|
||||
id="p-base-url"
|
||||
className="font-mono"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-proxy">代理(可选)</Label>
|
||||
<Input
|
||||
id="p-proxy"
|
||||
className="font-mono"
|
||||
placeholder="socks5://user:[email protected]:1080 · http://127.0.0.1:8080"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
仅 LLM 出站请求走此代理,支持 http/https/socks5,可带账号密码(如 socks5://user:pass@host:port,密码含特殊字符需 URL 编码);留空表示不使用代理(直连)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">API Key</Label>
|
||||
<Input
|
||||
id="p-api-key"
|
||||
type="password"
|
||||
placeholder={keyHint ? `已设置(${keyHint}),留空保持不变` : "sk-…"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-rps">每秒限速</Label>
|
||||
<Input id="p-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-rpm">每分钟限速</Label>
|
||||
<Input id="p-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-cw">上下文窗口(K)</Label>
|
||||
<Input
|
||||
id="p-cw"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={cw}
|
||||
onChange={(e) => setCw(e.target.value)}
|
||||
placeholder="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="-mt-2 text-muted-foreground text-xs">
|
||||
限速 0 = 不限,全 Agent 共享。上下文窗口单位 K(千 token),0 = 默认 200K,上限 1000(即
|
||||
1M);设太高会导致压缩不触发。
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label htmlFor="p-priority" className="text-sm">
|
||||
轮询优先级
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
数字越大越先被选中;激活配置恒为第 1 顺位,与本值无关。相同优先级的配置会轮流打头,天然分摊额度。
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="p-priority"
|
||||
type="number"
|
||||
className="w-24 shrink-0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">不参与轮询</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 适合「只给某个 Agent
|
||||
专用、不希望别人失败时烧掉」的昂贵配置。
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label="不参与轮询" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考开关 · thinking.type</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持
|
||||
的模型);关闭=发 disabled;开启=发 enabled。与下面的强度互相独立。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={thinkingType} onValueChange={setThinkingType}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_TYPES.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考强度 · reasoning_effort</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。
|
||||
有些接口没有 thinking 字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t px-4 py-3">
|
||||
<Button variant="outline" onClick={testConnection} disabled={testing}>
|
||||
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
|
||||
{testing ? "测试中…" : "测试连接"}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} className="flex-1">
|
||||
{saving && <Loader2Icon className="animate-spin" />}
|
||||
{!saving && (isNew ? <PlusIcon /> : <SaveIcon />)}
|
||||
{isNew ? "新建" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LLMPage() {
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [pool, setPool] = React.useState<LLMPoolStatus | null>(null);
|
||||
const [poolOpen, setPoolOpen] = React.useState(false);
|
||||
// 抽屉的开关和内容分开存:关闭时 editing 保持不变,否则关闭动画期间标题会从
|
||||
// 「编辑 X」闪成「新建」。editing = null 表示新建。
|
||||
const [editOpen, setEditOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<LLMProfile | null>(null);
|
||||
const openEditor = React.useCallback((p: LLMProfile | null) => {
|
||||
setEditing(p);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const loadPool = React.useCallback(async () => {
|
||||
try {
|
||||
setPool(await api.llmPool());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setProfiles(await api.llmProfiles());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await loadPool();
|
||||
}, [loadPool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// 卡片上的健康徽章按 profile id 取轮询状态。
|
||||
const health = React.useMemo(() => {
|
||||
const m = new Map<string, LLMPoolMember>();
|
||||
for (const c of pool?.chain ?? []) m.set(c.profile_id, c);
|
||||
return m;
|
||||
}, [pool]);
|
||||
|
||||
async function activate(id: string, name: string) {
|
||||
>>>>>>> Stashed changes
|
||||
try {
|
||||
await api.activateLLMProfile(id);
|
||||
const p = profiles.find((x) => x.id === id);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CpuIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
|
||||
import { CpuIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/lib/api";
|
||||
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
@@ -32,6 +33,11 @@ export default function SystemSettingsPage() {
|
||||
const [pyInterp, setPyInterp] = React.useState("");
|
||||
const [workers, setWorkers] = React.useState("3");
|
||||
const [savingWorkers, setSavingWorkers] = React.useState(false);
|
||||
// 操作约束注入范围(默认都开)。
|
||||
const [injectPlanner, setInjectPlanner] = React.useState(true);
|
||||
const [injectWorker, setInjectWorker] = React.useState(true);
|
||||
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
|
||||
const sendMode = useChatSendMode();
|
||||
|
||||
const apply = React.useCallback((s: Settings) => {
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
@@ -42,6 +48,8 @@ export default function SystemSettingsPage() {
|
||||
setProxyInput(s.web_search_proxy || "");
|
||||
setPyInterp(s.python_interpreter || "");
|
||||
setWorkers(String(s.workers ?? 3));
|
||||
setInjectPlanner(s.constraints_inject_planner !== false);
|
||||
setInjectWorker(s.constraints_inject_worker !== false);
|
||||
}, []);
|
||||
|
||||
const saveWorkers = () => {
|
||||
@@ -74,7 +82,11 @@ export default function SystemSettingsPage() {
|
||||
};
|
||||
const detectPython = () => {
|
||||
setSaving(true);
|
||||
api.detectPython().then((r) => setPyInterp(r.python_interpreter)).catch(() => undefined).finally(() => setSaving(false));
|
||||
api
|
||||
.detectPython()
|
||||
.then((r) => setPyInterp(r.python_interpreter))
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -95,6 +107,22 @@ export default function SystemSettingsPage() {
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectPlanner = (v: boolean) => {
|
||||
setInjectPlanner(v); // optimistic
|
||||
api
|
||||
.setSettings({ constraints_inject_planner: v })
|
||||
.then(apply)
|
||||
.catch(() => setInjectPlanner(!v)); // revert on failure
|
||||
};
|
||||
|
||||
const toggleInjectWorker = (v: boolean) => {
|
||||
setInjectWorker(v); // optimistic
|
||||
api
|
||||
.setSettings({ constraints_inject_worker: v })
|
||||
.then(apply)
|
||||
.catch(() => setInjectWorker(!v)); // revert on failure
|
||||
};
|
||||
|
||||
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
|
||||
const saveWebSearch = (patch: Partial<Settings>) => {
|
||||
setSaving(true);
|
||||
@@ -181,249 +209,333 @@ export default function SystemSettingsPage() {
|
||||
<p className="text-muted-foreground text-sm">全局运行时开关</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
流量捕获
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get
|
||||
工具与代理配置(提示词含代理说明)。
|
||||
<br />
|
||||
关闭(默认)时不记录任何流量:Agent
|
||||
<b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
|
||||
{trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="traffic-capture"
|
||||
checked={trafficCapture}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleTraffic}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<SearchIcon className="size-4" />
|
||||
网络搜索
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
|
||||
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>记录代理,独立于流量捕获。
|
||||
<br />
|
||||
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或 <b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
|
||||
{webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
|
||||
{/* 多列而非 grid:网络搜索卡片比其余高数倍,且高度随所选后端变化(brave/tavily
|
||||
的 key 输入是条件渲染)。grid 会按最高的一张撑满整行、在旁边留下大片空白,
|
||||
多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
|
||||
column-gap 只管列间距,行间距要由子元素自己给。 */}
|
||||
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
流量捕获
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get
|
||||
工具与代理配置(提示词含代理说明)。
|
||||
<br />
|
||||
关闭(默认)时不记录任何流量:Agent
|
||||
<b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
|
||||
{trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="web-search"
|
||||
checked={webSearch}
|
||||
id="traffic-capture"
|
||||
checked={trafficCapture}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={(v) => {
|
||||
setWebSearch(v); // optimistic
|
||||
saveWebSearch({ web_search_enabled: v });
|
||||
}}
|
||||
onCheckedChange={toggleTraffic}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{webSearch && (
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
操作约束注入
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,把每个任务的<b>操作约束</b>(在任务总览「操作约束」里维护的 allow/deny 条目)拼进对应 Agent
|
||||
的系统提示,用来框定探索边界(如「仅测当前端口」「禁止爆破」)。
|
||||
<br />
|
||||
可分别控制注入到 <b>规划者(planner)</b>与 <b>执行者(worker)</b>;默认都开。切换即时生效(下一轮读取),无需重建
|
||||
Agent。关闭后该 Agent 不再看到约束。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="text-sm font-normal text-muted-foreground">搜索来源</Label>
|
||||
<Select
|
||||
value={backend}
|
||||
<Label htmlFor="inject-planner" className="text-sm font-normal text-muted-foreground">
|
||||
注入规划者(planner){injectPlanner ? " · 已开启" : " · 已关闭"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="inject-planner"
|
||||
checked={injectPlanner}
|
||||
disabled={!loaded}
|
||||
onCheckedChange={toggleInjectPlanner}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="inject-worker" className="text-sm font-normal text-muted-foreground">
|
||||
注入执行者(worker){injectWorker ? " · 已开启" : " · 已关闭"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="inject-worker"
|
||||
checked={injectWorker}
|
||||
disabled={!loaded}
|
||||
onCheckedChange={toggleInjectWorker}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<SearchIcon className="size-4" />
|
||||
网络搜索
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
|
||||
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>
|
||||
记录代理,独立于流量捕获。
|
||||
<br />
|
||||
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或{" "}
|
||||
<b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
|
||||
{webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="web-search"
|
||||
checked={webSearch}
|
||||
disabled={!loaded || saving}
|
||||
onValueChange={(v) => {
|
||||
setBackend(v); // optimistic
|
||||
saveWebSearch({ web_search_backend: v });
|
||||
onCheckedChange={(v) => {
|
||||
setWebSearch(v); // optimistic
|
||||
saveWebSearch({ web_search_enabled: v });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48 shrink-0">
|
||||
<SelectValue placeholder="选择来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
|
||||
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
|
||||
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "brave-free" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
|
||||
Brave Search API Key
|
||||
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="brave-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"}
|
||||
value={braveKeyInput}
|
||||
disabled={!loaded || savingKey}
|
||||
onChange={(e) => setBraveKeyInput(e.target.value)}
|
||||
/>
|
||||
{webSearch && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="text-sm font-normal text-muted-foreground">搜索来源</Label>
|
||||
<Select
|
||||
value={backend}
|
||||
disabled={!loaded || saving}
|
||||
onValueChange={(v) => {
|
||||
setBackend(v); // optimistic
|
||||
saveWebSearch({ web_search_backend: v });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48 shrink-0">
|
||||
<SelectValue placeholder="选择来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
|
||||
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
|
||||
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "brave-free" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
|
||||
Brave Search API Key
|
||||
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="brave-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"}
|
||||
value={braveKeyInput}
|
||||
disabled={!loaded || savingKey}
|
||||
onChange={(e) => setBraveKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveBraveKey}
|
||||
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{braveNeedsKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "tavily" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
|
||||
Tavily Search API Key
|
||||
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="tavily-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"}
|
||||
value={tavilyKeyInput}
|
||||
disabled={!loaded || savingTavilyKey}
|
||||
onChange={(e) => setTavilyKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveTavilyKey}
|
||||
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{webSearch && backend === "tavily" && !tavilyKeySet && (
|
||||
<p className="text-xs text-amber-500">
|
||||
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">前往 https://tavily.com 注册并获取 API Key。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
出口代理(可选)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="ws-proxy"
|
||||
autoComplete="off"
|
||||
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
|
||||
value={proxyInput}
|
||||
disabled={!loaded || savingProxy}
|
||||
onChange={(e) => setProxyInput(e.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveBraveKey}
|
||||
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={!loaded || testing}
|
||||
className="shrink-0"
|
||||
>
|
||||
保存
|
||||
{testing ? "测试中…" : "测试搜索"}
|
||||
</Button>
|
||||
</div>
|
||||
{braveNeedsKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{webSearch && backend === "tavily" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
|
||||
Tavily Search API Key
|
||||
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="tavily-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"}
|
||||
value={tavilyKeyInput}
|
||||
disabled={!loaded || savingTavilyKey}
|
||||
onChange={(e) => setTavilyKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveTavilyKey}
|
||||
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{webSearch && backend === "tavily" && !tavilyKeySet && (
|
||||
<p className="text-xs text-amber-500">
|
||||
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
前往 https://tavily.com 注册并获取 API Key。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
出口代理(可选)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="ws-proxy"
|
||||
autoComplete="off"
|
||||
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
|
||||
value={proxyInput}
|
||||
disabled={!loaded || savingProxy}
|
||||
onChange={(e) => setProxyInput(e.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={runTest} disabled={!loaded || testing} className="shrink-0">
|
||||
{testing ? "测试中…" : "测试搜索"}
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
自定义脚本 · Python 解释器
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
|
||||
特定版本的绝对路径,留空则运行时自动检测。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-mono text-sm"
|
||||
placeholder="/usr/bin/python3(留空=自动检测)"
|
||||
value={pyInterp}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setPyInterp(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
|
||||
重新检测
|
||||
</Button>
|
||||
<Button onClick={savePython} disabled={!loaded || saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
自定义脚本 · Python 解释器
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
|
||||
特定版本的绝对路径,留空则运行时自动检测。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-mono text-sm"
|
||||
placeholder="/usr/bin/python3(留空=自动检测)"
|
||||
value={pyInterp}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setPyInterp(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
|
||||
重新检测
|
||||
</Button>
|
||||
<Button onClick={savePython} disabled={!loaded || saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CpuIcon className="size-4" />
|
||||
工作并发 · Work Agent 数
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后
|
||||
<b>对之后启动的任务生效</b>,正在运行的任务不受影响。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-32 font-mono text-sm"
|
||||
placeholder="3"
|
||||
value={workers}
|
||||
disabled={!loaded || savingWorkers}
|
||||
onChange={(e) => setWorkers(e.target.value)}
|
||||
/>
|
||||
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CpuIcon className="size-4" />
|
||||
工作并发 · Work Agent 数
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后<b>对之后启动的任务生效</b>,正在运行的任务不受影响。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-32 font-mono text-sm"
|
||||
placeholder="3"
|
||||
value={workers}
|
||||
disabled={!loaded || savingWorkers}
|
||||
onChange={(e) => setWorkers(e.target.value)}
|
||||
/>
|
||||
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyboardIcon className="size-4" />
|
||||
会话输入框发送键位
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
对话页与任务详情的主 Agent 会话输入框共用此设置,选择后立即生效、无需保存。
|
||||
<br />
|
||||
该偏好<b>只存在本浏览器</b>,不随账号同步,换浏览器或清理站点数据后需重新设置。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
|
||||
发送方式
|
||||
</Label>
|
||||
<Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
|
||||
<SelectTrigger id="chat-send-mode" className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHAT_SEND_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
<<<<<<< Updated upstream
|
||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
@@ -15,6 +16,73 @@ import type {
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
WorkspaceListing, WorkspaceFile,
|
||||
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask,
|
||||
=======
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
Audit,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
CommandRecord,
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
ConvTokenSummary,
|
||||
CoverageAssetRefs,
|
||||
CoverageGraphData,
|
||||
DailyTokenBucket,
|
||||
DeleteTaskOptions,
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
JudgeConfig,
|
||||
LLMPoolStatus,
|
||||
LLMProfile,
|
||||
LLMRecordDetail,
|
||||
LLMRecordItem,
|
||||
LLMTask,
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
MissingSkill,
|
||||
ModelTokenStat,
|
||||
PromptVar,
|
||||
PromptVersion,
|
||||
SessionTokenUsage,
|
||||
Settings,
|
||||
Severity,
|
||||
SkillCall,
|
||||
SkillItem,
|
||||
SSProject,
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskConstraint,
|
||||
TaskGoal,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
TaskTemplate,
|
||||
TokenTotal,
|
||||
TokenUsage,
|
||||
Tool,
|
||||
TrafficDetail,
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
UsageStats,
|
||||
WorkspaceFile,
|
||||
WorkspaceListing,
|
||||
>>>>>>> Stashed changes
|
||||
} from "@/lib/types";
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -112,6 +180,7 @@ export const api = {
|
||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||
|
||||
// ---- tasks ----
|
||||
<<<<<<< Updated upstream
|
||||
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) =>
|
||||
post<Task>("/tasks", {
|
||||
@@ -124,6 +193,64 @@ export const api = {
|
||||
}),
|
||||
deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`),
|
||||
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
=======
|
||||
tasks: () =>
|
||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (input: {
|
||||
name?: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
llmProfileIds?: number[];
|
||||
sourceTaskIds?: string[];
|
||||
companyIds?: number[];
|
||||
timeoutSeconds?: number;
|
||||
seedFirstIntent?: boolean;
|
||||
planHeartbeatSeconds?: number;
|
||||
coverageEnabled?: boolean;
|
||||
}) =>
|
||||
post<Task>("/tasks", {
|
||||
name: input.name ?? "",
|
||||
description: input.description,
|
||||
goal: input.goal,
|
||||
llm_profile_ids: input.llmProfileIds ?? [],
|
||||
source_task_ids: input.sourceTaskIds ?? [],
|
||||
company_ids: input.companyIds ?? [],
|
||||
timeout_seconds: input.timeoutSeconds ?? 0,
|
||||
seed_first_intent: input.seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||
}),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||
post<TaskTemplate>("/task-templates", input),
|
||||
updateTaskTemplate: (id: number, input: Partial<Pick<TaskTemplate, "name" | "description" | "goal">>) =>
|
||||
patch<TaskTemplate>(`/task-templates/${id}`, input),
|
||||
deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`),
|
||||
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
|
||||
put<{
|
||||
id: string;
|
||||
llm_profile_ids: number[];
|
||||
active_llm_profile_id?: number;
|
||||
llm_failover_state: string;
|
||||
reopened_intents: number;
|
||||
switch_event?: Activity;
|
||||
}>(`/tasks/${id}/llm`, {
|
||||
llm_profile_ids: llmProfileIds,
|
||||
active_llm_profile_id: activeLLMProfileId ?? null,
|
||||
}),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") =>
|
||||
post<{ id: string; paused: boolean; queued: boolean; status: string }>(`/tasks/${id}/control`, { action }),
|
||||
controlTasksBatch: (taskIds: string[], action: "pause" | "resume") =>
|
||||
post<{ items: BatchControlItem[] }>("/tasks/control/batch", { task_ids: taskIds, action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) =>
|
||||
post<{
|
||||
id: number;
|
||||
state: "paused" | "open" | "stopped";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
|
||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||
>>>>>>> Stashed changes
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
|
||||
@@ -134,6 +261,7 @@ export const api = {
|
||||
// 资产测试覆盖度(粗估,供参考):范围内资产被 fact 碰过的占比 + 按类型的 总数/已测。
|
||||
taskCoverage: (id: string) =>
|
||||
get<{
|
||||
enabled: boolean; // 资产覆盖度功能是否开启;false 时其余字段为零值
|
||||
scope_rows: number;
|
||||
denominator: number;
|
||||
tested: number;
|
||||
@@ -141,8 +269,46 @@ export const api = {
|
||||
by_type: { type: string; total: number; tested: number }[];
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
<<<<<<< Updated upstream
|
||||
taskCoverageGraph: (id: string) =>
|
||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
=======
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
// 本任务全部目标(text/vulnclass/state)。
|
||||
taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`),
|
||||
// 人工新增目标:写入图谱并通知 planner、复活任务。
|
||||
addGoal: (id: string, text: string, vulnclass?: string) =>
|
||||
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
|
||||
// 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。
|
||||
updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) =>
|
||||
patch<TaskGoal>(`/tasks/${id}/goals/${goalId}`, { text, vulnclass: vulnclass ?? "" }),
|
||||
// 人工删除目标(硬删除):通知 planner,删除不复活任务。
|
||||
deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`),
|
||||
|
||||
// ---- 操作约束管理(总览)----
|
||||
// 本任务全部操作约束(allow/deny)。
|
||||
taskConstraints: (id: string) => get<{ constraints: TaskConstraint[] }>(`/tasks/${id}/constraints`),
|
||||
// 新增约束(不通知 planner,下一轮规划自然读到)。
|
||||
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
|
||||
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
|
||||
// 修改约束(文本 + allow/deny)。
|
||||
updateConstraint: (id: string, constraintId: string, text: string, kind: TaskConstraint["kind"]) =>
|
||||
patch<TaskConstraint>(`/tasks/${id}/constraints/${constraintId}`, { text, kind }),
|
||||
// 删除约束。
|
||||
deleteConstraint: (id: string, constraintId: string) =>
|
||||
del<{ ok: boolean }>(`/tasks/${id}/constraints/${constraintId}`),
|
||||
|
||||
// 本任务测试范围列表(含继承自来源任务的范围)。
|
||||
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
|
||||
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
|
||||
addTaskScope: (id: string, kind: string, value: string, reason?: string) =>
|
||||
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
||||
// 删除本任务的一条测试范围。
|
||||
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
||||
>>>>>>> Stashed changes
|
||||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||||
taskAssetRefs: (id: string, assetId: number) =>
|
||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
@@ -621,6 +787,10 @@ export const api = {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
},
|
||||
|
||||
// ---- intercept LLM judge (模型兜底审批,全局配置) ----
|
||||
interceptGetJudgeConfig: () => get<JudgeConfig>("/intercept/judge"),
|
||||
interceptSetJudgeConfig: (cfg: JudgeConfig) => put<{ ok: boolean }>("/intercept/judge", cfg),
|
||||
|
||||
// ---- commands (tool execution history, any tool) ----
|
||||
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
|
||||
// 会话输入框的发送/换行键位。纯前端偏好:只落 localStorage,不入库、不随账号同步,
|
||||
// 因此换浏览器需要重设。见 issue #39——0.3.2 把 Ctrl+Enter 发送改成了 Enter 发送,
|
||||
// 这里把旧键位还回来作为可选项。
|
||||
export type ChatSendMode = "enter" | "ctrl-enter";
|
||||
|
||||
export const CHAT_SEND_MODE_KEY = "artex_chat_send_mode";
|
||||
export const DEFAULT_CHAT_SEND_MODE: ChatSendMode = "enter";
|
||||
|
||||
export const CHAT_SEND_MODE_OPTIONS: { value: ChatSendMode; label: string }[] = [
|
||||
{ value: "enter", label: "Enter 发送,Shift+Enter 换行" },
|
||||
{ value: "ctrl-enter", label: "Ctrl+Enter 发送,Enter 换行" },
|
||||
];
|
||||
|
||||
function parseMode(raw: string | null): ChatSendMode {
|
||||
return raw === "ctrl-enter" || raw === "enter" ? raw : DEFAULT_CHAT_SEND_MODE;
|
||||
}
|
||||
|
||||
// 同一标签页内的订阅者集合。localStorage 的 storage 事件只在「其他」标签页触发,
|
||||
// 本页在设置里改完后要靠 emit 通知同页的输入框,否则得刷新才生效。
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
window.addEventListener("storage", listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", listener);
|
||||
};
|
||||
}
|
||||
|
||||
// 返回的是字符串字面量,Object.is 按值比较,不会让 useSyncExternalStore 陷入循环。
|
||||
function getSnapshot(): ChatSendMode {
|
||||
return parseMode(getLocalStorageValue(CHAT_SEND_MODE_KEY));
|
||||
}
|
||||
|
||||
// 服务端没有 localStorage,先渲染默认值,hydrate 后 getSnapshot 再纠正。
|
||||
function getServerSnapshot(): ChatSendMode {
|
||||
return DEFAULT_CHAT_SEND_MODE;
|
||||
}
|
||||
|
||||
export function useChatSendMode(): ChatSendMode {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export function setChatSendMode(mode: ChatSendMode) {
|
||||
setLocalStorageValue(CHAT_SEND_MODE_KEY, mode);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// shouldSubmitOnKey 判断一次按键是否应当发送。
|
||||
// isComposing / keyCode 229 是中文等输入法正在选字,必须放行,否则回车选词会误发送。
|
||||
// enter 模式只排除 Shift,与 0.3.2 的行为逐字保持一致——不改设置的用户手感不变。
|
||||
// ctrl-enter 模式同时接受 Ctrl 与 Cmd(macOS)。
|
||||
export function shouldSubmitOnKey(e: React.KeyboardEvent, mode: ChatSendMode): boolean {
|
||||
if (e.key !== "Enter") return false;
|
||||
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return false;
|
||||
if (mode === "ctrl-enter") return e.ctrlKey || e.metaKey;
|
||||
return !e.shiftKey;
|
||||
}
|
||||
@@ -698,14 +698,14 @@ export const interceptRules: InterceptRule[] = [
|
||||
];
|
||||
|
||||
export const interceptPending: InterceptPending[] = [
|
||||
{ id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", created_at: T("2026-07-26T03:58:00Z") },
|
||||
{ id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", reason: "数据外泄门控:整库导出需人工审批", created_at: T("2026-07-26T03:58:00Z") },
|
||||
];
|
||||
|
||||
export const interceptHistory: InterceptApprovalRow[] = [
|
||||
{ id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "nmap -sV 203.0.113.11" }, status: "allowed", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
{ id: 92, rule_id: 2, task_id: "t-acme-web", agent_name: "work#13", tool_name: "bash", tool_input: { command: "impacket-secretsdump acme.local/[email protected] -just-dc" }, status: "allowed", created_at: T("2026-07-26T00:05:00Z"), decided_at: T("2026-07-26T00:06:00Z"), conv_title: "", conv_agent_key: "", rule_name: "外发流量直接拒绝" },
|
||||
{ id: 93, rule_id: 1, task_id: "t-acme-web", agent_name: "work#7", tool_name: "bash", tool_input: { command: "java -jar JNDIExploit.jar -i <vps> -l 1389" }, status: "allowed", created_at: T("2026-07-25T15:14:00Z"), decided_at: T("2026-07-25T15:15:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", reason: "破坏性命令需审批", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "rm -rf /var/www/html" }, status: "denied", reason: "[模型] 删除目标生产文件(D4)", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
{ id: 92, rule_id: 2, task_id: "t-acme-web", agent_name: "work#13", tool_name: "bash", tool_input: { command: "impacket-secretsdump acme.local/[email protected] -just-dc" }, status: "allowed", reason: "外发流量直接拒绝", created_at: T("2026-07-26T00:05:00Z"), decided_at: T("2026-07-26T00:06:00Z"), conv_title: "", conv_agent_key: "", rule_name: "外发流量直接拒绝" },
|
||||
{ id: 93, task_id: "t-acme-web", agent_name: "work#7", tool_name: "bash", tool_input: { command: "mysql -e \"DROP TABLE users_bak_0921\"" }, status: "pending", reason: "[模型] 疑似备份表,无法确定是否生产数据", created_at: T("2026-07-25T15:14:00Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
];
|
||||
|
||||
// ── Conversations (chat) ─────────────────────────────────────────────────────
|
||||
@@ -753,6 +753,7 @@ export const conversationMessages: Record<number, Activity[]> = {
|
||||
|
||||
// ── 资产测试覆盖度(/tasks/{id}/coverage)──
|
||||
export const coverage = {
|
||||
enabled: true,
|
||||
scope_rows: 4,
|
||||
denominator: 26,
|
||||
tested: 11,
|
||||
|
||||
+474
-2
@@ -6,6 +6,189 @@ import * as D from "./data";
|
||||
|
||||
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
||||
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
||||
const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||
const mockConversations = structuredClone(D.conversations);
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
let mockActiveTask = D.ACTIVE_TASK;
|
||||
|
||||
function mockAssetCounts(): Record<string, number> {
|
||||
return mockAssets.reduce<Record<string, number>>((counts, asset) => {
|
||||
counts[asset.type] = (counts[asset.type] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mockScopeRows(
|
||||
companyID: number,
|
||||
input: unknown,
|
||||
existing: ScopeRow[] = [],
|
||||
): { rows: ScopeRow[]; invalid: number; skipped: number } {
|
||||
if (!Array.isArray(input)) return { rows: [], invalid: 0, skipped: 0 };
|
||||
if (input.length > MAX_COMPANY_SCOPE_RULES) throw new Error(`企业范围最多支持 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
let nextID =
|
||||
mockCompanies.flatMap((company) => company.scope ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
|
||||
const rows: ScopeRow[] = [];
|
||||
let invalid = 0;
|
||||
let skipped = 0;
|
||||
const keys = new Set(
|
||||
existing.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.raw.trim().toLowerCase()}`),
|
||||
);
|
||||
for (const [index, candidate] of input.entries()) {
|
||||
let rule: CompanyScopeRule | undefined;
|
||||
if (typeof candidate === "string") {
|
||||
rule = classifyCompanyScopeLine(candidate, index + 1).rule;
|
||||
} else if (candidate && typeof candidate === "object") {
|
||||
const item = candidate as { kind?: unknown; value?: unknown };
|
||||
const value = String(item.value ?? "").trim();
|
||||
if (item.kind === undefined || item.kind === "") rule = classifyCompanyScopeLine(value, index + 1).rule;
|
||||
else if (isCompanyScopeKind(item.kind)) rule = { kind: item.kind, value };
|
||||
}
|
||||
if (!rule || companyScopeRuleError(rule)) {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeCompanyScopeValue(rule);
|
||||
const row: ScopeRow = { id: nextID++, company_id: companyID, kind: rule.kind, raw: rule.value.trim() };
|
||||
if (rule.kind === "domain") row.domain = normalized;
|
||||
else if (rule.kind === "ip") row.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
|
||||
else if (rule.kind === "cidr") row.net = normalized;
|
||||
else row.value = normalized;
|
||||
const key = `${row.kind}|${row.domain ?? row.net ?? row.value}`;
|
||||
if (keys.has(key)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
rows.push(row);
|
||||
}
|
||||
if (existing.length + rows.length > MAX_COMPANY_SCOPE_RULES) {
|
||||
throw new Error(`企业范围规则过多:每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
}
|
||||
return { rows, invalid, skipped };
|
||||
}
|
||||
|
||||
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
|
||||
const profile = D.llmProfiles.find((item) => Number(item.id) === profileID);
|
||||
if (!profile) {
|
||||
return { name: "", format: "", model: "", source, available: false, reason: "LLM 配置不存在" };
|
||||
}
|
||||
return {
|
||||
profile_id: Number(profile.id),
|
||||
name: profile.name,
|
||||
format: profile.format,
|
||||
model: profile.model,
|
||||
source,
|
||||
available: Boolean(profile.api_key_hint),
|
||||
reason: profile.api_key_hint ? undefined : "LLM 配置未设置 API Key",
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the backend precedence in server/task_resolution.go:
|
||||
// Agent 绑定 → 任务 LLM 配置链 → 全局配置 → 环境配置。
|
||||
function mockRoleResolution(task: Task, agentKey: "mainagent" | "planner" | "worker"): TaskLLMResolution {
|
||||
const agent = D.agents.find((item) => item.key === agentKey);
|
||||
if (agent?.llm_profile_id) {
|
||||
const bound = mockProfileResolution(agent.llm_profile_id, "agent_binding");
|
||||
if (bound.available) return bound;
|
||||
}
|
||||
if (task.llm_profile_ids?.length) {
|
||||
if (task.active_llm_profile_id === undefined) {
|
||||
return {
|
||||
name: "",
|
||||
format: "",
|
||||
model: "",
|
||||
source: "task_chain",
|
||||
available: false,
|
||||
reason: "任务 LLM 配置链额度已耗尽",
|
||||
};
|
||||
}
|
||||
return mockProfileResolution(task.active_llm_profile_id, "task_chain");
|
||||
}
|
||||
const globalProfile = D.llmProfiles.find((item) => item.is_default);
|
||||
if (globalProfile) return mockProfileResolution(Number(globalProfile.id), "global_profile");
|
||||
return {
|
||||
name: "全局配置",
|
||||
format: D.llmConfig.provider,
|
||||
model: D.llmConfig.model,
|
||||
source: "environment",
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
function bodyIDs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids = [...new Set(value.map((item) => String(item)).filter(Boolean))];
|
||||
if (ids.length > 100) throw new Error("批量操作最多支持 100 个 ID");
|
||||
return ids;
|
||||
}
|
||||
|
||||
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
if (!intent) return { ok: false, error: "意图不存在" };
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
if (intent.inherited || intent.state !== requiredState) {
|
||||
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
}
|
||||
intent.state = action === "pause" ? "paused" : "open";
|
||||
return { ok: true, state: intent.state };
|
||||
}
|
||||
|
||||
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
const task = mockTasks.find((item) => item.id === id);
|
||||
if (!task) return { id, ok: false, error: "任务不存在" };
|
||||
if (task.status === "done" || task.status === "failed" || task.status === "timeout") {
|
||||
return { id, ok: false, status: task.status, error: "终态任务不可控制" };
|
||||
}
|
||||
if (action === "pause") {
|
||||
if (task.paused || task.status === "paused") {
|
||||
return { id, ok: false, status: task.status, error: "任务已经暂停" };
|
||||
}
|
||||
task.paused = true;
|
||||
task.queued = false;
|
||||
task.status = "paused";
|
||||
task.engine_mode = "paused";
|
||||
} else {
|
||||
if (!task.paused && task.status !== "paused") {
|
||||
return { id, ok: false, status: task.status, error: "任务未暂停" };
|
||||
}
|
||||
task.paused = false;
|
||||
task.queued = false;
|
||||
task.status = "running";
|
||||
task.engine_mode = "exploring";
|
||||
}
|
||||
return { id, ok: true, status: task.status, queued: false };
|
||||
}
|
||||
|
||||
function sortMockConversations() {
|
||||
mockConversations.sort((a, b) => {
|
||||
const aPinned = a.pinned_at ? 1 : 0;
|
||||
const bPinned = b.pinned_at ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
const aTime = a.pinned_at ?? a.updated_at;
|
||||
const bTime = b.pinned_at ?? b.updated_at;
|
||||
return bTime.localeCompare(aTime) || b.id - a.id;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedTemplateName(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
if (typeof body !== "string") return {};
|
||||
try {
|
||||
@@ -34,11 +217,213 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/auth/change-password") return { ok: true };
|
||||
|
||||
// ── tasks ──
|
||||
<<<<<<< Updated upstream
|
||||
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
|
||||
if (path === "/tasks" && m === "POST")
|
||||
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
|
||||
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
|
||||
=======
|
||||
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
|
||||
if (path === "/tasks" && m === "POST") {
|
||||
let suffix = 1;
|
||||
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
|
||||
const id = `t-new-${suffix}`;
|
||||
const now = new Date();
|
||||
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
|
||||
const created: Task = {
|
||||
id,
|
||||
name: String(b.name ?? ""),
|
||||
description: String(b.description ?? "新任务"),
|
||||
goal: String(b.goal ?? ""),
|
||||
status: "created",
|
||||
created_at: now.toISOString(),
|
||||
created_unix: Math.floor(now.getTime() / 1000),
|
||||
paused: false,
|
||||
active: true,
|
||||
in_flight: 0,
|
||||
stalled: false,
|
||||
goals_total: 0,
|
||||
goals_met: 0,
|
||||
engine_mode: "idle",
|
||||
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
llm_profile_id: profileIDs[0],
|
||||
llm_profile_ids: profileIDs,
|
||||
active_llm_profile_id: profileIDs[0],
|
||||
llm_failover_state: profileIDs.length ? "ready" : "default",
|
||||
source_task_ids: sourceTaskIDs,
|
||||
company_ids: companyIDs,
|
||||
};
|
||||
for (const item of mockTasks) item.active = false;
|
||||
mockTasks.unshift(created);
|
||||
mockActiveTask = id;
|
||||
return created;
|
||||
}
|
||||
if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates };
|
||||
if (path === "/task-templates" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
const name = normalizedTemplateName(b.name);
|
||||
const description = String(b.description ?? "").trim();
|
||||
const goal = String(b.goal ?? "").trim();
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some((template) => normalizedTemplateName(template.name).toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
const nextID = mockTaskTemplates.reduce((max, template) => Math.max(max, template.id), 0) + 1;
|
||||
const created: TaskTemplate = {
|
||||
id: nextID,
|
||||
name,
|
||||
description,
|
||||
goal,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockTaskTemplates.unshift(created);
|
||||
return created;
|
||||
}
|
||||
if (seg[0] === "task-templates" && seg.length === 2 && m === "PATCH") {
|
||||
const template = mockTaskTemplates.find((item) => item.id === Number(seg[1]));
|
||||
if (!template) return {};
|
||||
const name = typeof b.name === "string" ? normalizedTemplateName(b.name) : template.name;
|
||||
const description = typeof b.description === "string" ? b.description.trim() : template.description;
|
||||
const goal = typeof b.goal === "string" ? b.goal.trim() : template.goal;
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some(
|
||||
(item) => item.id !== template.id && normalizedTemplateName(item.name).toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
template.name = name;
|
||||
template.description = description;
|
||||
template.goal = goal;
|
||||
template.updated_at = new Date().toISOString();
|
||||
mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id);
|
||||
return template;
|
||||
}
|
||||
if (seg[0] === "task-templates" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
const index = mockTaskTemplates.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
||||
return { deleted: id };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
||||
const id = seg[1];
|
||||
const index = mockTasks.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockTasks.splice(index, 1);
|
||||
|
||||
let findingsDeleted = 0;
|
||||
if (b.delete_findings) {
|
||||
for (let i = mockFindings.length - 1; i >= 0; i--) {
|
||||
if (mockFindings[i].task_id !== id) continue;
|
||||
mockFindings.splice(i, 1);
|
||||
findingsDeleted++;
|
||||
}
|
||||
} else {
|
||||
// PostgreSQL uses ON DELETE SET NULL for retained findings. Keep the mock
|
||||
// grouped view consistent by moving them into the unassigned/deleted bucket.
|
||||
for (const finding of mockFindings) {
|
||||
if (finding.task_id !== id) continue;
|
||||
finding.task_id = undefined;
|
||||
finding.task_description = "";
|
||||
}
|
||||
}
|
||||
|
||||
let llmRecordsDeleted = 0;
|
||||
if (b.delete_llm_records) {
|
||||
for (let i = mockLLMRecords.length - 1; i >= 0; i--) {
|
||||
if (mockLLMRecords[i].task_id !== id) continue;
|
||||
mockLLMRecords.splice(i, 1);
|
||||
llmRecordsDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mockActiveTask === id) {
|
||||
const nextActive = mockTasks[0]?.id ?? "";
|
||||
mockActiveTask = nextActive;
|
||||
for (const item of mockTasks) item.active = item.id === nextActive;
|
||||
}
|
||||
return {
|
||||
deleted: id,
|
||||
assets_deleted: b.delete_assets ? 1 : 0,
|
||||
assets_detached: 0,
|
||||
traffic_deleted: b.delete_traffic ? 1 : 0,
|
||||
files_deleted: Boolean(b.delete_files),
|
||||
findings_deleted: findingsDeleted,
|
||||
llm_records_deleted: llmRecordsDeleted,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "llm" && m === "PUT") {
|
||||
const ids = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const activeID = typeof b.active_llm_profile_id === "number" ? b.active_llm_profile_id : ids[0];
|
||||
const target = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (target) {
|
||||
target.llm_profile_ids = ids;
|
||||
target.llm_profile_id = ids[0];
|
||||
target.active_llm_profile_id = activeID;
|
||||
target.llm_failover_state = ids.length ? "ready" : "default";
|
||||
target.llm_failover_reason = undefined;
|
||||
}
|
||||
return {
|
||||
id: seg[1],
|
||||
llm_profile_ids: ids,
|
||||
active_llm_profile_id: activeID,
|
||||
llm_failover_state: ids.length ? "ready" : "default",
|
||||
reopened_intents: 0,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "llm" && seg[3] === "resolution" && m === "GET") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
return {
|
||||
mainagent: mockRoleResolution(task, "mainagent"),
|
||||
planner: mockRoleResolution(task, "planner"),
|
||||
worker: mockRoleResolution(task, "worker"),
|
||||
};
|
||||
}
|
||||
if (path === "/tasks/control/batch" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "control" && m === "POST") {
|
||||
const id = seg[3];
|
||||
if (b.action === "cancel") {
|
||||
const index = mockIntents.findIndex((item) => item.id === id);
|
||||
const intent = mockIntents[index];
|
||||
if (!intent || intent.inherited || (intent.state !== "running" && intent.state !== "paused")) {
|
||||
throw new Error("Worker 状态已变化");
|
||||
}
|
||||
mockIntents.splice(index, 1);
|
||||
return {
|
||||
id: Number(id.replace(/\D/g, "")) || 0,
|
||||
state: "cancelled",
|
||||
deleted: {
|
||||
intents: 1,
|
||||
facts: 1,
|
||||
findings: 1,
|
||||
activities: D.activity.filter((item) => item.intent_id === id).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
const result = controlMockIntent(id, action);
|
||||
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
|
||||
return { id: Number(id.replace(/\D/g, "")) || 0, state: result.state };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 3 && seg[2] === "control" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
const result = controlMockTask(seg[1], action);
|
||||
if (!result.ok) throw new Error(result.error ?? "任务状态已变化");
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
return { id: seg[1], paused: Boolean(task?.paused), queued: Boolean(task?.queued), status: task?.status ?? "" };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "status") return { running: false };
|
||||
>>>>>>> Stashed changes
|
||||
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
|
||||
if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
|
||||
|
||||
@@ -76,14 +461,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/findings/stats") {
|
||||
const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
|
||||
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
|
||||
<<<<<<< Updated upstream
|
||||
const taskMap = new Map<string, { description: string; count: number }>();
|
||||
for (const f of D.findings) {
|
||||
=======
|
||||
const taskMap = new Map<string, { name: string; description: string; count: number }>();
|
||||
for (const f of mockFindings) {
|
||||
>>>>>>> Stashed changes
|
||||
if (!f.task_id) continue;
|
||||
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
|
||||
const owner = mockTasks.find((candidate) => candidate.id === f.task_id);
|
||||
const cur = taskMap.get(f.task_id) ?? { name: owner?.name ?? "", description: f.task_description ?? "", count: 0 };
|
||||
cur.count++;
|
||||
taskMap.set(f.task_id, cur);
|
||||
}
|
||||
const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
|
||||
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
|
||||
return {
|
||||
total: D.findings.length,
|
||||
pending: D.findings.filter((f) => f.status === "pending").length,
|
||||
@@ -95,6 +486,76 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
if (path === "/exploration/findings/groups") {
|
||||
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
let list = mockFindings.slice();
|
||||
const filterSeverity = q.get("severity");
|
||||
const filterStatus = q.get("status");
|
||||
const filterVulnclass = q.get("vulnclass");
|
||||
const filterTask = q.get("task_id");
|
||||
if (filterSeverity) list = list.filter((finding) => finding.severity === filterSeverity);
|
||||
if (filterStatus) list = list.filter((finding) => finding.status === filterStatus);
|
||||
if (filterVulnclass) list = list.filter((finding) => finding.vulnclass === filterVulnclass);
|
||||
if (filterTask === "__unassigned__") list = list.filter((finding) => !finding.task_id);
|
||||
else if (filterTask) list = list.filter((finding) => finding.task_id === filterTask);
|
||||
|
||||
const grouped = new Map<string, typeof list>();
|
||||
for (const finding of list) {
|
||||
const key = finding.task_id ?? "__unassigned__";
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), finding]);
|
||||
}
|
||||
const groups = Array.from(grouped, ([key, items]) => {
|
||||
const owner = mockTasks.find((candidate) => candidate.id === key);
|
||||
return {
|
||||
task_id: key === "__unassigned__" ? null : key,
|
||||
task_name: owner?.name ?? "",
|
||||
task_description: owner?.description ?? items[0]?.task_description ?? "",
|
||||
task_status: owner?.status ?? "",
|
||||
count: items.length,
|
||||
critical: items.filter((finding) => finding.severity === "critical").length,
|
||||
high: items.filter((finding) => finding.severity === "high").length,
|
||||
medium: items.filter((finding) => finding.severity === "medium").length,
|
||||
low: items.filter((finding) => finding.severity === "low").length,
|
||||
last_found_at: items.reduce((latest, finding) => (finding.ts > latest ? finding.ts : latest), ""),
|
||||
max_severity: Math.max(...items.map((finding) => severityOrder[finding.severity])),
|
||||
};
|
||||
});
|
||||
groups.sort((left, right) =>
|
||||
q.get("sort") === "severity"
|
||||
? right.max_severity - left.max_severity || right.last_found_at.localeCompare(left.last_found_at)
|
||||
: right.last_found_at.localeCompare(left.last_found_at),
|
||||
);
|
||||
const rawPage = Number(q.get("page") ?? 1);
|
||||
const rawPageSize = Number(q.get("limit") ?? 10);
|
||||
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(100, Math.floor(rawPageSize)) : 10;
|
||||
return {
|
||||
items: groups.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: groups.length,
|
||||
finding_total: list.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "deepen" && m === "POST") {
|
||||
const finding = mockFindings.find((candidate) => candidate.id === seg[2]);
|
||||
const description = String(b.description ?? "").trim();
|
||||
if (!description) throw new Error("description is required");
|
||||
if ([...description].length > 4000) throw new Error("description must be at most 4000 characters");
|
||||
if (!finding) throw new Error("finding not found");
|
||||
if (!finding.task_id || !mockTasks.some((candidate) => candidate.id === finding.task_id)) {
|
||||
throw new Error("finding origin task or node is no longer available");
|
||||
}
|
||||
return {
|
||||
task_id: finding.task_id,
|
||||
intent_id: `mock-deepen-${Date.now()}`,
|
||||
state: "open",
|
||||
queued: false,
|
||||
};
|
||||
}
|
||||
>>>>>>> Stashed changes
|
||||
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
||||
if (
|
||||
seg[0] === "exploration" &&
|
||||
@@ -257,6 +718,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "intercept" && seg[1] === "task")
|
||||
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
|
||||
if (path === "/intercept/judge" && m === "GET")
|
||||
return {
|
||||
enabled: false,
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
};
|
||||
if (path === "/intercept/judge" && m === "PUT") return { ok: true };
|
||||
|
||||
// ── 写操作兜底:成功但不落库 ──
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
|
||||
|
||||
@@ -6,6 +6,7 @@ export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||
description: string;
|
||||
goal: string;
|
||||
status: TaskStatus;
|
||||
@@ -24,6 +25,44 @@ export interface Task {
|
||||
engine_mode?: EngineMode;
|
||||
tokens?: TokenTotal; // whole-task token consumption
|
||||
llm_profile_id?: number; // LLM profile used; absent = default profile
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
llm_profile_ids?: number[]; // ordered task-level failover chain
|
||||
active_llm_profile_id?: number; // profile used by the next LLM call
|
||||
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
|
||||
llm_failover_reason?: string;
|
||||
source_task_ids?: string[]; // directly related tasks inherited as read-only context
|
||||
company_ids?: number[]; // associated company asset scopes available as task context
|
||||
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DeleteTaskOptions {
|
||||
delete_assets: boolean;
|
||||
delete_traffic: boolean;
|
||||
delete_files: boolean;
|
||||
delete_findings: boolean;
|
||||
delete_llm_records: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteTaskResult {
|
||||
deleted: string;
|
||||
assets_deleted: number;
|
||||
assets_detached: number;
|
||||
traffic_deleted: number;
|
||||
files_deleted: boolean;
|
||||
findings_deleted: number;
|
||||
llm_records_deleted: number;
|
||||
cleanup_warning?: string;
|
||||
>>>>>>> Stashed changes
|
||||
}
|
||||
|
||||
// ---- Asset graph (global, shared across tasks) ----
|
||||
@@ -198,6 +237,30 @@ export interface WorkspaceFile {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
|
||||
export interface TaskScopeRow {
|
||||
id: number;
|
||||
task_id: number;
|
||||
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
|
||||
company_id?: number;
|
||||
company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值
|
||||
domain?: string;
|
||||
net?: string;
|
||||
source: "auto" | "agent" | "manual";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type CompanyScopeKind = "domain" | "ip" | "cidr" | "icp" | "keyword";
|
||||
|
||||
// 新增企业时提交的结构化资产范围规则。
|
||||
export interface CompanyScopeRule {
|
||||
kind: CompanyScopeKind;
|
||||
value: string;
|
||||
}
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
@@ -236,6 +299,26 @@ export interface TaskNode {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
|
||||
export interface TaskGoal {
|
||||
id: string;
|
||||
text: string;
|
||||
vulnclass?: string;
|
||||
state: string; // GoalState
|
||||
origin?: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
// 约束管理卡片用的操作约束(allow=允许 / deny=禁止)。
|
||||
export type ConstraintKind = "allow" | "deny";
|
||||
export interface TaskConstraint {
|
||||
id: string;
|
||||
kind: ConstraintKind;
|
||||
text: string;
|
||||
origin?: string;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
// ---- Findings ----
|
||||
export type Severity = "critical" | "high" | "medium" | "low";
|
||||
|
||||
@@ -283,6 +366,37 @@ export interface FindingsPage {
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
export interface FindingGroup {
|
||||
task_id: string | number | null;
|
||||
task_name?: string; // 可选任务名称;空/缺省=未命名
|
||||
task_description: string;
|
||||
task_status: string;
|
||||
count: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
last_found_at: string;
|
||||
}
|
||||
|
||||
export interface FindingGroupsPage {
|
||||
items: FindingGroup[];
|
||||
total: number;
|
||||
finding_total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface FindingDeepenResponse {
|
||||
task_id: string;
|
||||
intent_id: string;
|
||||
state: IntentState;
|
||||
queued: boolean;
|
||||
}
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||
export interface FindingStats {
|
||||
total: number;
|
||||
@@ -298,7 +412,12 @@ export interface FindingStats {
|
||||
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
|
||||
// 前端回退展示 id)及其漏洞条数。
|
||||
export interface FindingTaskOption {
|
||||
<<<<<<< Updated upstream
|
||||
id: number;
|
||||
=======
|
||||
id: string | number;
|
||||
name?: string; // 可选任务名称;空/缺省=未命名
|
||||
>>>>>>> Stashed changes
|
||||
description: string;
|
||||
count: number;
|
||||
}
|
||||
@@ -498,6 +617,20 @@ export interface Settings {
|
||||
web_search_proxy?: string;
|
||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
|
||||
task_concurrency_enabled?: boolean; // 默认 false
|
||||
task_concurrency_limit?: number; // 开启后默认 5
|
||||
// LLM 轮询(故障转移)。默认关;开启后「未指定模型」的 agent 在当前配置不可用
|
||||
// (余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
|
||||
llm_pool_enabled?: boolean; // 默认 false
|
||||
// 绑定了指定配置的 agent/任务失败时是否也回落到轮询链。默认 false = 绑定即独占。
|
||||
llm_pool_bind_fallback?: boolean;
|
||||
// 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。
|
||||
constraints_inject_planner?: boolean;
|
||||
constraints_inject_worker?: boolean;
|
||||
>>>>>>> Stashed changes
|
||||
}
|
||||
|
||||
// ---- LLM config ----
|
||||
@@ -664,10 +797,22 @@ export interface InterceptPending {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tool_input: Record<string, any>;
|
||||
status: "pending" | "allowed" | "denied" | "timeout";
|
||||
reason: string; // 规则 message 或模型判定理由(模型判定带 [模型] 前缀)
|
||||
decided_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// JudgeConfig: 模型兜底审批(仅当没有任何拦截规则命中时由模型判断)的全局配置。
|
||||
export interface JudgeConfig {
|
||||
enabled: boolean;
|
||||
profile_id: number; // 0 = 跟随激活/默认配置
|
||||
prompt: string; // 判定提示词;GET 未设置时后端回填内置模板全文
|
||||
timeout_seconds: number; // 模型调用超时
|
||||
fail_action: "allow" | "ask" | "deny"; // 模型出错/超时/不可解析时的回退
|
||||
ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时
|
||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
export interface InterceptApprovalRow extends InterceptPending {
|
||||
conv_title: string; // "" if no linked conversation
|
||||
|
||||
Reference in new issue
Block a user