Merge pull request #26 from RuoJi6/codex/task-intelligence

feat: 新增可选的任务情报自动记录与查看
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-20 13:23:26 +08:00
commit 620ca5d96c
35 files changed
+3692 -58

No files matched your search

+82 -3
View File
@@ -2,6 +2,8 @@
import * as React from "react";
import { useSearchParams } from "next/navigation";
import {
BuildingIcon,
ChevronLeftIcon,
@@ -19,6 +21,7 @@ import {
import { toast } from "sonner";
import { AssetDslSearch } from "@/components/asset-dsl-search";
import { IntelligencePanel } from "@/components/intelligence-panel";
import { ScopeTextEditor } from "@/components/scope-text-editor";
import {
AlertDialog,
@@ -107,7 +110,38 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [
{ key: "endpoint", label: "接口", icon: LinkIcon },
];
export default function AssetsPage() {
function AssetsContent() {
const searchParams = useSearchParams();
const requestedAsset = searchParams.get("asset") ?? "";
const [assetTarget, setAssetTarget] = React.useState<{ id: number; type: string }>();
const [assetTargetError, setAssetTargetError] = React.useState("");
React.useEffect(() => {
let alive = true;
setAssetTarget(undefined);
setAssetTargetError("");
if (!/^[1-9]\d*$/.test(requestedAsset)) return;
api
.searchAssets(`id==${requestedAsset}`, "", 1)
.then((r) => {
if (!alive) return;
const asset = r.assets[0];
if (!asset) {
setAssetTargetError(`资产 #${requestedAsset} 不存在或已删除`);
return;
}
setAssetTarget({ id: asset.id, type: asset.type });
setTab(asset.type);
setPage(0);
setIntelligenceAsset(null);
})
.catch((e) => {
if (alive) setAssetTargetError((e as Error).message);
});
return () => {
alive = false;
};
}, [requestedAsset]);
const [intelligenceAsset, setIntelligenceAsset] = React.useState<number | null>(null);
const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0);
const [companies, setCompanies] = React.useState<Company[]>([]);
@@ -230,14 +264,14 @@ export default function AssetsPage() {
// Reset query + page + selection when switching tabs
// biome-ignore lint/correctness/useExhaustiveDependencies: tab changes intentionally reset tab-local controls.
React.useEffect(() => {
setQuery("");
setQuery(assetTarget?.type === tab ? `id==${assetTarget.id}` : "");
setDslError("");
setSelected(new Set());
setRows([]);
setRowsTab("");
setTotal(0);
setLoaded(false);
}, [tab]);
}, [tab, assetTarget]);
// biome-ignore lint/correctness/useExhaustiveDependencies: these controls intentionally reset server pagination.
React.useEffect(() => setPage(0), [tab, size, query]);
@@ -314,6 +348,32 @@ export default function AssetsPage() {
return (
<div className="flex h-[calc(100vh-6rem)] min-h-0 flex-col gap-4">
{assetTargetError ? (
<p role="alert" className="text-sm text-destructive">
{assetTargetError}
</p>
) : null}
<Dialog
open={intelligenceAsset !== null}
onOpenChange={(open) => {
if (!open) setIntelligenceAsset(null);
}}
>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>资产 #{intelligenceAsset} 的关联情报</DialogTitle>
<DialogDescription>按来源任务查看凭据、线索和敏感数据。</DialogDescription>
</DialogHeader>
{intelligenceAsset !== null ? (
<IntelligencePanel
key={intelligenceAsset}
scope="assets"
scopeId={String(intelligenceAsset)}
onNavigate={() => setIntelligenceAsset(null)}
/>
) : null}
</DialogContent>
</Dialog>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">资产</h1>
@@ -322,6 +382,17 @@ export default function AssetsPage() {
<span className="text-sm text-muted-foreground">
共 <span className="tabular-nums">{totalAssets}</span> 项资产
</span>
{tab !== "company" ? (
<Button
variant="outline"
size="sm"
disabled={selected.size !== 1}
title="选择一项资产查看关联情报"
onClick={() => setIntelligenceAsset(Array.from(selected)[0])}
>
关联情报
</Button>
) : null}
{selected.size > 0 && (
<Button variant="destructive" size="sm" onClick={() => openDelete(Array.from(selected) as number[])}>
<Trash2Icon className="size-3.5" /> 删除已选 ({selected.size})
@@ -1214,3 +1285,11 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
</Dialog>
);
}
export default function AssetsPage() {
return (
<React.Suspense fallback={null}>
<AssetsContent />
</React.Suspense>
);
}
@@ -478,9 +478,32 @@ function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
);
}
export function SessionsTab({ taskId }: { taskId: string }) {
export function SessionsTab({ taskId, requestedSession = "" }: { taskId: string; requestedSession?: string }) {
const approvalFocus = useApprovalFocus({ taskId });
const [selectedSessionId, setActiveId] = React.useState(MAIN_ID);
const [linkedSession, setLinkedSession] = React.useState<Session>();
const hasRequestedSession = React.useRef(false);
hasRequestedSession.current = /^(intent:[1-9]\d*|main:\d+|plan)$/.test(requestedSession);
React.useEffect(() => {
let session: Session | undefined;
if (/^intent:[1-9]\d*$/.test(requestedSession)) {
const id = requestedSession.slice(7);
session = {
id,
intent_id: id,
role: "worker",
title: `Worker #${id}`,
status: "done",
live: false,
last_activity: "",
};
} else if (/^main:\d+$/.test(requestedSession)) {
const seg = Number(requestedSession.slice(5));
session = { ...MAIN_SESSION, id: mainSessionId(seg), seg, title: mainSessionTitle(seg) };
} else if (requestedSession === "plan") session = PLANNER_SESSION;
setLinkedSession(session);
if (session) setActiveId(session.id);
}, [requestedSession]);
const focusSession = React.useMemo<Session | undefined>(() => {
const source = approvalFocus.state?.source;
if (!source) return undefined;
@@ -887,7 +910,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setSessionTokens({});
setLLMResolutions(null);
setSseLive(false);
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
if (!hasRequestedSession.current) setActiveId(MAIN_ID); // preserve an explicit session deep link
setMainSegs([{ seq: 0, created_at: "" }]);
setCurrentSeg(0);
snapshotRef.current = 0;
@@ -949,7 +972,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
bootKey = mainSessionKey(ms.current);
if (bootKey !== mainSessionKey(0)) loadingKeysRef.current.delete(mainSessionKey(0));
loadingKeysRef.current.add(bootKey);
setActiveId(mainSessionId(ms.current));
if (!hasRequestedSession.current) setActiveId(mainSessionId(ms.current));
patchStore(bootKey, (s) => ({ ...s, loading: true }));
return api.activityHistory(taskId, bootKey, 0, PAGE);
})
@@ -969,7 +992,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
});
// Open the single task SSE from the snapshot cursor.
const es = openSSE(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`);
const es = openSSE(
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
);
esRef.current = es;
es.onopen = () => setSseLive(true);
es.onerror = () => setSseLive(false); // EventSource auto-reconnects; DB compensates the gap
@@ -1244,10 +1269,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const sessions = React.useMemo(() => {
const items = [...mainSessions, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, SYSTEM_SESSION];
const located = focusSession ?? retainedSession;
const located = focusSession ?? retainedSession ?? linkedSession;
if (located && !items.some((s) => s.id === located.id)) items.push(located);
return items;
}, [mainSessions, workerSessions, plannerLive, focusSession, retainedSession]);
}, [mainSessions, workerSessions, plannerLive, focusSession, retainedSession, linkedSession]);
const grouped = {
mainagent: sessions.filter((s) => s.role === "mainagent"),
@@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
import { toast } from "sonner";
import { IntelligencePanel } from "@/components/intelligence-panel";
import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -56,6 +57,7 @@ const TABS = [
{ value: "graph", label: "探索链路" },
{ value: "broadcast", label: "播报板" },
{ value: "findings", label: "发现" },
{ value: "intelligence", label: "任务情报" },
{ value: "retests", label: "复测" },
{ value: "assets", label: "测试资产" },
{ value: "coverage", label: "资产覆盖图" },
@@ -209,7 +211,11 @@ function TaskDetailInner() {
const [task, setTask] = React.useState<Task | null>(null);
const [paused, setPaused] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const requestedTab = searchParams.get("tab") ?? "sessions";
const [tab, setTab] = React.useState(TABS.some((item) => item.value === requestedTab) ? requestedTab : "sessions");
React.useEffect(() => {
if (TABS.some((item) => item.value === requestedTab)) setTab(requestedTab);
}, [requestedTab]);
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [archiving, setArchiving] = React.useState(false);
@@ -418,7 +424,7 @@ function TaskDetailInner() {
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
<TabsContent value="sessions" className="mt-0">
<SessionsTab taskId={id} />
<SessionsTab key={id} taskId={id} requestedSession={searchParams.get("session") ?? ""} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
@@ -429,6 +435,9 @@ function TaskDetailInner() {
<TabsContent value="broadcast" className="mt-0">
<BroadcastTab taskId={id} />
</TabsContent>
<TabsContent value="intelligence" className="mt-0">
<IntelligencePanel scope="tasks" scopeId={id} />
</TabsContent>
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
+97 -3
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -15,10 +16,15 @@ 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";
import { UpdateCard } from "./_components/update-card";
import { BasicAuthCard } from "./_components/basic-auth-card";
import { UpdateCard } from "./_components/update-card";
export default function SystemSettingsPage() {
const [credentialAgent, setCredentialAgent] = React.useState(false);
const [credentialBinding, setCredentialBinding] = React.useState(false);
const [savingCredentials, setSavingCredentials] = React.useState(false);
const [intelligenceEnabled, setIntelligenceEnabled] = React.useState(false);
const [savingIntelligence, setSavingIntelligence] = React.useState(false);
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
@@ -48,6 +54,9 @@ export default function SystemSettingsPage() {
const sendMode = useChatSendMode();
const apply = React.useCallback((s: Settings) => {
setCredentialAgent(!!s.credential_agent_enabled);
setCredentialBinding(!!s.credential_traffic_binding);
setIntelligenceEnabled(!!s.task_intelligence_enabled);
setTrafficCapture(!!s.traffic_capture);
setAgentTrafficBinding(!!s.agent_traffic_binding);
setWebSearch(!!s.web_search_enabled);
@@ -255,6 +264,20 @@ export default function SystemSettingsPage() {
// brave-free selected but no key stored and none being entered → tool stays off.
const braveNeedsKey = webSearch && backend === "brave-free" && !braveKeySet;
const saveCredentialOptions = async (patch: Partial<Settings>) => {
setSavingCredentials(true);
try {
const s = await api.setSettings(patch);
setCredentialAgent(!!s.credential_agent_enabled);
setCredentialBinding(!!s.credential_traffic_binding);
setIntelligenceEnabled(!!s.task_intelligence_enabled);
toast.success("凭据处理设置已保存");
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSavingCredentials(false);
}
};
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
@@ -434,6 +457,77 @@ export default function SystemSettingsPage() {
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle>任务情报</CardTitle>
<CardDescription>自动记录任务中获取的凭据、重要线索和敏感数据,并保留来源证据。</CardDescription>
</CardHeader>
<CardContent>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="task-intelligence">Agent 自动记录</FieldLabel>
<FieldDescription>
默认关闭。任务执行时上报凭据、重要线索和敏感数据,会增加任务 Agent 的提示与上报
Token。仅负责记录,不启动凭据整理。关闭后同时停用下方 Agent 与流量绑定,已有情报和整理结果仍可查看。
</FieldDescription>
</FieldContent>
<Switch
id="task-intelligence"
checked={intelligenceEnabled}
disabled={!loaded || savingIntelligence || savingCredentials}
onCheckedChange={async (value) => {
setSavingIntelligence(true);
try {
const settings = await api.setSettings({ task_intelligence_enabled: value });
setIntelligenceEnabled(!!settings.task_intelligence_enabled);
setCredentialAgent(!!settings.credential_agent_enabled);
setCredentialBinding(!!settings.credential_traffic_binding);
toast.success(value ? "任务情报自动记录已开启" : "任务情报自动记录已关闭");
} catch (error) {
toast.error("保存失败:" + (error as Error).message);
} finally {
setSavingIntelligence(false);
}
}}
/>
</Field>
<Field orientation="horizontal" className="mt-5">
<FieldContent>
<FieldLabel htmlFor="credential-agent">Agent 开关</FieldLabel>
<FieldDescription>
默认关闭。独立整理已有和新增凭据,拆分并高亮账号、密码、Token 和
Cookie,不改变原始上报或验证状态。复杂文本会使用额外模型 Token;可解析的 JSON
无需模型。需先开启上方自动记录。模型可在「Agent → 凭据整理」中配置。
</FieldDescription>
</FieldContent>
<Switch
id="credential-agent"
checked={credentialAgent}
disabled={!loaded || savingCredentials || savingIntelligence || !intelligenceEnabled}
onCheckedChange={(value) => void saveCredentialOptions({ credential_agent_enabled: value })}
/>
</Field>
{credentialAgent ? (
<Field orientation="horizontal" className="mt-5">
<FieldContent>
<FieldLabel htmlFor="credential-traffic">绑定流量</FieldLabel>
<FieldDescription>
默认关闭,仅在 Agent
开关开启时可用。核对已抓取流量中的资产与凭据,关联真实请求或响应并保存匹配片段;不主动登录,也不把匹配流量视为验证成功。关闭
Agent 时同时停用,已有绑定保留。
</FieldDescription>
</FieldContent>
<Switch
id="credential-traffic"
checked={credentialBinding}
disabled={!loaded || savingCredentials}
onCheckedChange={(value) => void saveCredentialOptions({ credential_traffic_binding: value })}
/>
</Field>
) : null}
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -446,8 +540,8 @@ export default function SystemSettingsPage() {
记录代理,独立于流量捕获。
<br />
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)、{" "}
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(复用当前 LLM 配置)。总开关关闭时,各
Agent 的网络搜索开关不可用。
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(复用当前 LLM 配置)。总开关关闭时,各 Agent
的网络搜索开关不可用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
+375
View File
@@ -0,0 +1,375 @@
"use client";
import * as React from "react";
import { ArrowUpRightIcon, ChevronDownIcon, ChevronUpIcon, CopyIcon, NetworkIcon } from "lucide-react";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import { toneClasses } from "@/lib/status";
import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types";
import { cn, copyText } from "@/lib/utils";
export function intelligencePreview(text: string, limit = 260) {
const chars = Array.from(text);
return chars.length > limit ? `${chars.slice(0, limit).join("")}…` : text;
}
const FIELDS = [
["username", "账号", "blue"],
["password", "密码", "violet"],
["token", "Token / API Key", "violet"],
["cookie", "Cookie", "amber"],
] as const;
function HighlightedOriginal({ text, accounts }: { text: string; accounts: CredentialAccount[] }) {
const parts = React.useMemo(() => {
const values = [
...new Set(accounts.flatMap((a) => [a.username, a.password, a.token, a.cookie]).filter((v): v is string => !!v)),
]
.sort((a, b) => b.length - a.length)
.slice(0, 200);
if (!values.length) return [text];
const re = new RegExp(`(${values.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "g");
return text.split(re);
}, [text, accounts]);
return (
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{parts.map((part, i) =>
i % 2 ? (
<mark key={`${i}-${part}`} className="rounded bg-primary/15 px-0.5 text-primary">
{part}
</mark>
) : (
part
),
)}
</pre>
);
}
function TrafficList({
traffic,
detail,
onTraffic,
onDetails,
}: {
traffic: CredentialTraffic[];
detail: boolean;
onTraffic?: (id: string) => void;
onDetails?: () => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const limit = detail ? 3 : 2;
const visible = detail && expanded ? traffic : traffic.slice(0, limit);
const remaining = traffic.length - limit;
return (
<section aria-label="关联流量" className="min-w-0 space-y-2 rounded-lg border bg-muted/20 p-2.5">
<div className="flex items-center gap-1.5 text-xs font-medium">
<NetworkIcon className="size-3.5 text-muted-foreground" />
关联流量
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{traffic.length} 条</span>
</div>
<div className={cn("space-y-1.5", detail && expanded && "max-h-80 overflow-y-auto pr-1")}>
{visible.map((ref) => {
let host = "",
path = ref.url;
try {
const url = new URL(ref.url);
host = url.host;
path = `${url.pathname}${url.search}${url.hash}`;
} catch {
// Retain non-URL references as plain text.
}
const tone =
ref.status >= 500
? "red"
: ref.status >= 400
? "amber"
: ref.status >= 300
? "blue"
: ref.status >= 200
? "green"
: "neutral";
return (
<button
key={ref.id}
type="button"
title={ref.url}
aria-label={`查看流量 ${ref.method} ${ref.url},编号 ${ref.id}`}
onClick={() => onTraffic?.(ref.id)}
className="group flex w-full min-w-0 flex-col gap-1.5 rounded-md border bg-background p-2 text-left transition-colors hover:border-primary/40 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<Badge variant="outline" className="shrink-0 font-mono text-[10px]">
{ref.method || "HTTP"}
</Badge>
{ref.status > 0 ? (
<Badge variant="outline" className={cn("shrink-0 font-mono text-[10px]", toneClasses[tone])}>
{ref.status}
</Badge>
) : null}
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{host || "流量记录"}</span>
<ArrowUpRightIcon className="size-3.5 shrink-0 text-muted-foreground group-hover:text-primary" />
</span>
<span className="block w-full truncate font-mono text-xs">{path}</span>
<span className="text-[11px] text-muted-foreground">
{ref.side === "request" ? "请求中使用" : "响应中发现"}
</span>
</button>
);
})}
</div>
{remaining > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-7 w-full text-xs text-muted-foreground"
aria-expanded={detail ? expanded : undefined}
onClick={detail ? () => setExpanded(!expanded) : onDetails}
>
{detail ? (expanded ? "收起流量" : `展开其余 ${remaining} 条流量`) : `还有 ${remaining} 条流量,查看详情`}
{detail ? expanded ? <ChevronUpIcon /> : <ChevronDownIcon /> : <ArrowUpRightIcon />}
</Button>
) : null}
</section>
);
}
export function CredentialContent({
item,
detail = false,
enabled = false,
onTraffic,
onDetails,
}: {
item: TaskIntelligence;
detail?: boolean;
enabled?: boolean;
onTraffic?: (id: string) => void;
onDetails?: () => void;
}) {
const analysis = item.kind === "credential" ? item.credentials : undefined;
const accounts = analysis?.accounts ?? [];
const ready = accounts.length > 0;
const visibleAccounts = detail ? accounts : accounts.slice(0, 2);
const fieldsTruncated =
!detail &&
visibleAccounts.some((a) =>
[a.username, a.password, a.token, a.cookie, a.endpoint, a.label, a.protocol].some(
(v) => v && Array.from(v).length > 80,
),
);
let pretty = item.content;
try {
pretty = JSON.stringify(JSON.parse(item.content), null, 2);
} catch {
// Plain-text reports keep their original representation.
}
return (
<div className="flex min-w-0 flex-col gap-2">
{analysis && analysis.state !== "error" ? (
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
<Badge variant="outline">
{analysis.state === "processing"
? "正在整理"
: analysis.state === "queued"
? "等待整理"
: analysis.method === "rules"
? "规则整理"
: "Agent 整理"}
</Badge>
{analysis.method === "rules" ? <span>未调用模型</span> : null}
{detail && analysis.model ? (
<span>
{analysis.model} ·{" "}
{analysis.input_tokens + analysis.output_tokens > 0
? `输入 ${analysis.input_tokens} / 输出 ${analysis.output_tokens} Token`
: "模型未返回 Token 用量"}
</span>
) : null}
</div>
) : !analysis && enabled && item.kind === "credential" ? (
<span className="text-xs text-muted-foreground">等待凭据整理</span>
) : null}
{ready ? (
<section
className={cn("flex min-w-0 flex-col gap-2 overflow-y-auto", detail ? "max-h-96" : "max-h-80")}
aria-label="结构化凭据"
>
{visibleAccounts.map((account, index) => (
<div
key={`${index}-${account.username ?? account.token ?? account.cookie}`}
className="flex min-w-0 flex-col gap-2 rounded-lg border p-2"
>
<div className="flex flex-wrap gap-1 text-xs text-muted-foreground">
<span>凭据 {index + 1}</span>
{account.label ? (
<span>· {detail ? account.label : intelligencePreview(account.label, 80)}</span>
) : null}
{account.protocol ? (
<Badge variant="outline" className="max-w-full whitespace-normal break-all">
{detail ? account.protocol : intelligencePreview(account.protocol, 80)}
</Badge>
) : null}
</div>
{account.endpoint ? (
<span className="break-all text-xs text-muted-foreground">
{detail ? account.endpoint : intelligencePreview(account.endpoint, 80)}
</span>
) : null}
<dl className="flex min-w-0 flex-col gap-1.5">
{FIELDS.map(([field, label, tone]) =>
account[field] ? (
<div key={field} className="flex min-w-0 items-start gap-2">
<dt className="min-w-10 shrink-0 pt-1 text-xs text-muted-foreground">{label}</dt>
<dd className="flex min-w-0 flex-1 items-start gap-1">
<code
data-credential-field={field}
className={cn(
"min-w-0 flex-1 whitespace-pre-wrap break-all rounded border px-1.5 py-1 font-mono text-xs",
toneClasses[tone],
)}
>
{detail ? account[field] : intelligencePreview(account[field] ?? "", 80)}
</code>
<Button
variant="ghost"
size="icon-xs"
aria-label={`复制${label} ${index + 1}`}
onClick={async () => {
if (await copyText(account[field] ?? "")) toast.success(`${label}已复制`);
else toast.error("复制失败,请手动选择内容复制");
}}
>
<CopyIcon />
</Button>
</dd>
</div>
) : null,
)}
</dl>
</div>
))}
</section>
) : null}
{!detail && ready && (accounts.length > 2 || fieldsTruncated) ? (
<Button variant="link" size="sm" className="h-auto justify-start p-0 text-xs" onClick={onDetails}>
{accounts.length > 2 ? `还有 ${accounts.length - 2} 组凭据,查看详情` : "查看完整凭据"}
</Button>
) : null}
{ready && analysis?.note ? <p className="text-xs text-muted-foreground">{analysis.note}</p> : null}
{analysis?.traffic?.length ? (
<TrafficList
key={item.id}
traffic={analysis.traffic}
detail={detail}
onTraffic={onTraffic}
onDetails={onDetails}
/>
) : null}
{detail && analysis?.traffic_note ? (
<p className="text-xs text-muted-foreground">{analysis.traffic_note}</p>
) : null}
{!ready || detail ? (
<div className={cn(detail ? "max-h-72 overflow-auto rounded-lg bg-muted p-3" : "")}>
{ready ? <p className="mb-2 text-xs text-muted-foreground">原始上报内容</p> : null}
<HighlightedOriginal text={detail ? pretty : intelligencePreview(pretty)} accounts={accounts} />
{!detail && Array.from(pretty).length > 260 ? (
<Button variant="link" size="sm" className="h-auto justify-start p-0 text-xs" onClick={onDetails}>
查看完整内容
</Button>
) : null}
</div>
) : null}
</div>
);
}
export function CredentialTrafficViewer({
scope,
scopeId,
item,
trafficId,
onClose,
}: {
scope: "tasks" | "assets";
scopeId: string;
item: TaskIntelligence | null;
trafficId: string | null;
onClose: () => void;
}) {
const [data, setData] = React.useState<CredentialTrafficDetail | null>(null);
const [error, setError] = React.useState("");
React.useEffect(() => {
if (!item || !trafficId) return;
let alive = true;
setData(null);
setError("");
api
.credentialTraffic(scope, scopeId, item.id, trafficId)
.then((d) => {
if (alive) setData(d);
})
.catch((e: Error) => {
if (alive) setError(e.message);
});
return () => {
alive = false;
};
}, [scope, scopeId, item, trafficId]);
return (
<Dialog
open={!!trafficId}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>凭据关联流量</DialogTitle>
<DialogDescription className="break-all">{data?.reference.url ?? "读取已绑定的真实流量"}</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : data ? (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{data.reference.method} · HTTP {data.reference.status} ·{" "}
{data.reference.side === "request" ? "请求包含匹配凭据" : "响应包含匹配凭据"},不代表认证验证成功。
</p>
<p className="text-sm">绑定时保存的匹配片段</p>
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3 text-xs">
{data.reference.excerpt}
</pre>
{data.unavailable ? (
<Alert>
<AlertDescription>原始流量已删除或不可用,仍可查看上方保存的匹配片段。</AlertDescription>
</Alert>
) : (
<Tabs defaultValue={data.reference.side}>
<TabsList>
<TabsTrigger value="request">请求</TabsTrigger>
<TabsTrigger value="response">响应</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={data.req ?? ""} />
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={data.resp ?? ""} />
</TabsContent>
</Tabs>
)}
</div>
) : (
<Skeleton className="h-40" />
)}
</DialogContent>
</Dialog>
);
}
+465
View File
@@ -0,0 +1,465 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { CopyIcon, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import { CredentialContent, CredentialTrafficViewer, intelligencePreview } from "@/components/credential-content";
import { StatusBadge, ToneDot } from "@/components/status-badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import { statusMeta } from "@/lib/status";
import type { IntelligenceKind, IntelligencePage, TaskIntelligence } from "@/lib/types";
import { copyText } from "@/lib/utils";
const KINDS: { value: IntelligenceKind; label: string }[] = [
{ value: "credential", label: "凭据" },
{ value: "lead", label: "重要线索" },
{ value: "sensitive_data", label: "敏感数据" },
];
const STATUS: Record<IntelligenceKind, Record<string, string>> = {
credential: { unverified: "未验证", verified: "已验证", invalid: "已失效" },
lead: { pending: "待跟进", in_progress: "跟进中", dismissed: "已排除", converted: "已转漏洞" },
sensitive_data: { suspected: "疑似", confirmed: "已确认", dismissed: "已排除" },
};
function time(value: string) {
return new Date(value).toLocaleString("zh-CN");
}
function conversationHref(item: TaskIntelligence) {
return `/function/tasks/detail?id=${item.task_id}&tab=sessions${item.source_session ? `&session=${encodeURIComponent(item.source_session)}` : ""}`;
}
function AssetLinks({ ids, onNavigate }: { ids: number[]; onNavigate?: () => void }) {
return ids.length ? (
<span className="inline-flex flex-wrap gap-x-2 gap-y-1">
{ids.map((id) => (
<Link
key={id}
href={`/function/assets?asset=${id}`}
onClick={onNavigate}
className="underline underline-offset-2"
aria-label={`查看资产 #${id}`}
>
#{id}
</Link>
))}
</span>
) : (
<>未关联</>
);
}
export function IntelligencePanel({
scope,
scopeId,
onNavigate,
}: {
scope: "tasks" | "assets";
scopeId: string;
onNavigate?: () => void;
}) {
const [kind, setKind] = React.useState("all");
const [status, setStatus] = React.useState("all");
const [search, setSearch] = React.useState("");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(0);
const [data, setData] = React.useState<IntelligencePage | null>(null);
const [error, setError] = React.useState("");
const [refresh, setRefresh] = React.useState(0);
const [selected, setSelected] = React.useState<TaskIntelligence | null>(null);
const [saving, setSaving] = React.useState(false);
const [retrying, setRetrying] = React.useState(false);
const [trafficTarget, setTrafficTarget] = React.useState<{ item: TaskIntelligence; id: string } | null>(null);
React.useEffect(() => {
if (data)
setSelected((current) => (current ? (data.items.find((item) => item.id === current.id) ?? current) : null));
}, [data]);
React.useEffect(() => {
const timer = setTimeout(() => {
setQuery(search.trim());
setPage(0);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads this resource after a user action.
React.useEffect(() => {
let alive = true;
let busy = false;
const load = async () => {
if (busy) return;
busy = true;
try {
const params = new URLSearchParams({ limit: "25", offset: String(page * 25) });
if (kind !== "all") params.set("kind", kind);
if (status !== "all") params.set("status", status);
if (query) params.set("q", query);
const result = await api.intelligence(scope, scopeId, params);
if (alive) {
setData(result);
setError("");
}
} catch (e) {
if (alive) setError((e as Error).message);
} finally {
busy = false;
}
};
void load();
const timer = setInterval(() => {
if (!document.hidden) void load();
}, 8000);
return () => {
alive = false;
clearInterval(timer);
};
}, [scope, scopeId, kind, status, query, page, refresh]);
const open = (item: TaskIntelligence) => setSelected(item);
const close = () => setSelected(null);
const navigate = () => {
close();
onNavigate?.();
};
const update = async (value: string) => {
if (!selected) return;
setSaving(true);
try {
await api.updateIntelligence(selected.task_id, selected.id, value);
setSelected((item) => (item ? { ...item, status: value } : null));
setRefresh((n) => n + 1);
toast.success("情报状态已更新");
} catch (e) {
toast.error("更新失败:" + (e as Error).message);
} finally {
setSaving(false);
}
};
const statusOptions = kind === "all" ? {} : STATUS[kind as IntelligenceKind];
return (
<div className="flex min-w-0 flex-col gap-4">
{data && !data.enabled && scope === "tasks" ? (
<Alert>
<AlertTitle>自动记录已关闭</AlertTitle>
<AlertDescription>
已有情报仍可查看。前往{" "}
<Link href="/system/settings" className="underline">
系统设置
</Link>{" "}
开启后,Agent 将在执行中记录新增情报,并产生额外 Token 消耗。
</AlertDescription>
</Alert>
) : null}
<Card>
<CardHeader>
<CardTitle>{scope === "tasks" ? "任务情报" : "资产关联情报"}</CardTitle>
<CardDescription>
{scope === "tasks" ? "集中查看本次获取与直接关联任务继承的情报。" : "按来源任务查看此资产关联的情报。"}{" "}
直接展示凭据、敏感数据及来源证据,发现凭据不代表验证成功。
</CardDescription>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-4">
<Tabs
value={kind}
onValueChange={(value) => {
setKind(value);
setStatus("all");
setPage(0);
}}
>
<TabsList className="max-w-full overflow-x-auto">
<TabsTrigger value="all">
全部 {data ? Object.values(data.counts).reduce((a, b) => a + b, 0) : ""}
</TabsTrigger>
{KINDS.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
<ToneDot tone={statusMeta("intelligence", item.value).tone} />
{item.label} {data?.counts[item.value] ?? 0}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-wrap items-center gap-2">
<Input
aria-label="搜索情报标题或上报者"
placeholder="搜索标题或上报者"
value={search}
maxLength={200}
onChange={(e) => setSearch(e.target.value)}
className="min-w-40 flex-1"
/>
{kind !== "all" ? (
<Select
value={status}
onValueChange={(value) => {
setStatus(value);
setPage(0);
}}
>
<SelectTrigger aria-label="筛选情报状态">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部状态</SelectItem>
{Object.entries(statusOptions).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : null}
<Button variant="outline" onClick={() => setRefresh((n) => n + 1)}>
<RefreshCwIcon data-icon="inline-start" />
刷新
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertTitle>读取情报失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!data && !error ? (
<Skeleton className="h-40 w-full" />
) : data?.items.length ? (
<Table className="min-w-[68rem] table-fixed">
<colgroup>
<col className="w-[20%]" />
<col className="w-[35%]" />
<col className="w-[8%]" />
<col className="w-[12%]" />
<col className="w-[6%]" />
<col className="w-[12%]" />
<col className="w-[7%]" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>类别 / 标题</TableHead>
<TableHead>内容 / 来源证据</TableHead>
<TableHead>状态</TableHead>
<TableHead>对话</TableHead>
<TableHead>资产</TableHead>
<TableHead>发现时间</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="align-top whitespace-normal">
<div className="flex min-w-0 flex-col items-start gap-1">
<StatusBadge domain="intelligence" value={item.kind} />
<span className="max-w-full [overflow-wrap:anywhere]">{item.title}</span>
</div>
</TableCell>
<TableCell className="align-top whitespace-normal">
<div className="flex min-w-0 flex-col gap-2">
<CredentialContent
item={item}
enabled={data.credential_agent_enabled}
onDetails={() => open(item)}
onTraffic={(id) => setTrafficTarget({ item, id })}
/>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>来源证据</span>
<p className="whitespace-pre-wrap break-all">{intelligencePreview(item.evidence, 220)}</p>
{Array.from(item.evidence).length > 220 ? (
<button type="button" className="text-left underline" onClick={() => open(item)}>
详情查看完整证据
</button>
) : null}
</div>
</div>
</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
<div className="flex flex-col items-start gap-1">
<Badge variant="outline">{STATUS[item.kind][item.status] ?? item.status}</Badge>
<span className="text-xs text-muted-foreground">
{item.confidence === "observed" ? "直接观察" : "推测"}
</span>
</div>
</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
<div className="flex flex-col gap-1">
<Link href={conversationHref(item)} onClick={navigate} className="underline">
{item.source_session ? "查看对话" : "查看任务对话"}
</Link>
<span className="text-xs text-muted-foreground">
任务 #{item.task_id} · {item.worker || "Agent"}
{item.node_id ? ` · 节点 #${item.node_id}` : ""}
</span>
{item.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
</div>
</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
<AssetLinks ids={item.asset_ids} onNavigate={navigate} />
</TableCell>
<TableCell className="align-top whitespace-normal text-xs">{time(item.created_at)}</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
<Button size="sm" variant="outline" onClick={() => open(item)}>
详情
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : data ? (
<Empty>
<EmptyHeader>
<EmptyTitle>暂无符合条件的情报</EmptyTitle>
<EmptyDescription>
{query || kind !== "all" || status !== "all"
? "尝试调整分类或筛选条件。"
: data.enabled
? "Agent 在后续执行中发现有价值的信息后,会在此显示。"
: "开启自动记录后,新增情报会在此显示;历史会话不会自动补提取。"}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
</CardContent>
<CardFooter className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">共 {data?.total ?? 0} 条 · 每页 25 条</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 0} onClick={() => setPage((n) => n - 1)}>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={!data || (page + 1) * 25 >= data.total}
onClick={() => setPage((n) => n + 1)}
>
下一页
</Button>
</div>
</CardFooter>
</Card>
<Dialog
open={!!selected}
onOpenChange={(value) => {
if (!value) close();
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selected?.title ?? "情报详情"}</DialogTitle>
<DialogDescription>完整展示记录内容与实际来源证据。</DialogDescription>
</DialogHeader>
{selected ? (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge domain="intelligence" value={selected.kind} />
<Badge variant="outline">{selected.confidence === "observed" ? "直接观察" : "推测"}</Badge>
<span className="text-xs text-muted-foreground">
任务 #{selected.task_id}
{selected.node_id ? ` · 来源节点 #${selected.node_id}` : ""} · {selected.worker || "Agent"}
</span>
</div>
{scope === "tasks" && !selected.inherited ? (
<Select value={selected.status} disabled={saving} onValueChange={(value) => void update(value)}>
<SelectTrigger aria-label="更新情报状态">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(STATUS[selected.kind]).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
{STATUS[selected.kind][selected.status]} · 请到来源任务修改状态
</p>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-sm">内容</span>
<Button
size="sm"
variant="ghost"
onClick={async () => {
if (await copyText(selected.content)) toast.success("已复制");
else toast.error("复制失败,请手动选择内容复制");
}}
>
<CopyIcon data-icon="inline-start" />
复制
</Button>
</div>
<CredentialContent
item={selected}
detail
enabled={data?.credential_agent_enabled}
onTraffic={(id) => setTrafficTarget({ item: selected, id })}
/>
{selected.kind === "credential" && !selected.inherited && data?.credential_agent_enabled ? (
<Button
variant="outline"
size="sm"
disabled={
retrying || selected.credentials?.state === "processing" || selected.credentials?.state === "queued"
}
onClick={async () => {
setRetrying(true);
try {
await api.retryCredentials(selected.task_id, selected.id);
setRefresh((n) => n + 1);
toast.success("已加入凭据处理队列");
} catch (e) {
toast.error((e as Error).message);
} finally {
setRetrying(false);
}
}}
>
重新处理凭据
</Button>
) : null}
<p className="text-sm">来源证据</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
{selected.evidence}
</pre>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
关联资产:
<AssetLinks ids={selected.asset_ids} onNavigate={navigate} />
</span>
<span>发现于 {time(selected.created_at)}</span>
</div>
<Link href={conversationHref(selected)} onClick={navigate} className="text-sm underline">
{selected.source_session ? "查看来源任务会话" : "查看来源任务"}
</Link>
</div>
) : null}
</DialogContent>
</Dialog>
<CredentialTrafficViewer
scope={scope}
scopeId={scopeId}
item={trafficTarget?.item ?? null}
trafficId={trafficTarget?.id ?? null}
onClose={() => setTrafficTarget(null)}
/>
</div>
);
}
+17 -1
View File
@@ -28,6 +28,7 @@ import type {
ConvTokenSummary,
CoverageAssetRefs,
CoverageGraphData,
CredentialTrafficDetail,
DailyTokenBucket,
DeleteTaskOptions,
DeleteTaskResult,
@@ -50,6 +51,7 @@ import type {
FindingsPage,
FindingTraffic,
FindingTrafficDetail,
IntelligencePage,
IntentAsset,
InterceptApprovalFilter,
InterceptApprovalRow,
@@ -91,6 +93,7 @@ import type {
TaskCategory,
TaskConstraint,
TaskGoal,
TaskIntelligence,
TaskLLMResolutions,
TaskNode,
TaskSchedule,
@@ -337,7 +340,8 @@ export const api = {
runScheduleNow: (id: number) =>
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) => patch<Task>(`/tasks/${id}`, input),
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) =>
patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
@@ -913,6 +917,18 @@ export const api = {
trafficDeleteAll: () => del<{ deleted: number; reclaimed: number }>(`/traffic/all`),
// ---- app settings (runtime toggles) ----
intelligence: (scope: "tasks" | "assets", id: string, query: URLSearchParams) =>
get<IntelligencePage>(`/${scope}/${id}/intelligence?${query.toString()}`),
intelligenceDetail: (scope: "tasks" | "assets", id: string, intelligenceId: number) =>
get<TaskIntelligence>(`/${scope}/${id}/intelligence/${intelligenceId}`),
updateIntelligence: (taskId: number, intelligenceId: number, status: string) =>
patch<{ updated: boolean }>(`/tasks/${taskId}/intelligence/${intelligenceId}`, { status }),
retryCredentials: (taskId: number, intelligenceId: number) =>
post<{ queued: boolean }>(`/tasks/${taskId}/intelligence/${intelligenceId}/credentials/retry`),
credentialTraffic: (scope: "tasks" | "assets", scopeId: string, intelligenceId: number, trafficId: string) =>
get<CredentialTrafficDetail>(
`/${scope}/${scopeId}/intelligence/${intelligenceId}/traffic/${encodeURIComponent(trafficId)}`,
),
settings: () => get<Settings>(`/settings`),
basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"),
setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) =>
+3
View File
@@ -3324,6 +3324,9 @@ export const tools: Tool[] = [
// ── Settings ─────────────────────────────────────────────────────────────────
export const settings: Settings = {
task_intelligence_enabled: false,
credential_agent_enabled: false,
credential_traffic_binding: false,
traffic_capture: true,
agent_traffic_binding: false,
llm_record: false,
+8
View File
@@ -109,7 +109,15 @@ const delivery: Record<string, StatusMeta> = {
skipped: { label: "已跳过", tone: "neutral" },
};
// Intelligence category colors are shared by task, asset and detail views.
const intelligence: Record<string, StatusMeta> = {
credential: { label: "凭据", tone: "violet" },
lead: { label: "重要线索", tone: "blue" },
sensitive_data: { label: "敏感数据", tone: "amber" },
};
const maps = {
intelligence,
intent,
task,
severity,
+69
View File
@@ -947,6 +947,9 @@ export interface TrafficHost {
// ---- App settings (runtime toggles) ----
export interface Settings {
traffic_capture: boolean;
credential_agent_enabled: boolean;
credential_traffic_binding: boolean;
task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示
agent_traffic_binding: boolean; // Agent 自动绑定流量证据,默认关闭;不影响人工绑定
llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用
// Web search. brave_key_set / tavily_key_set reflect whether a key is stored
@@ -1741,3 +1744,69 @@ export interface FindingCaseReviewRun {
state: "queued" | "running" | "done" | "failed";
error: string;
}
export type IntelligenceKind = "credential" | "lead" | "sensitive_data";
export interface CredentialAccount {
label?: string;
username?: string;
password?: string;
token?: string;
cookie?: string;
endpoint?: string;
protocol?: string;
}
export interface CredentialTraffic {
id: string;
url: string;
method: string;
status: number;
side: string;
excerpt: string;
accounts: number[];
}
export interface CredentialAnalysis {
state: "queued" | "processing" | "ready" | "error";
method?: "rules" | "agent";
accounts: CredentialAccount[] | null;
traffic: CredentialTraffic[] | null;
traffic_state: string;
traffic_note?: string;
note?: string;
error?: string;
model?: string;
input_tokens: number;
output_tokens: number;
processed_at?: string;
}
export interface CredentialTrafficDetail {
reference: CredentialTraffic;
req?: string;
resp?: string;
unavailable: boolean;
}
export interface TaskIntelligence {
credentials?: CredentialAnalysis;
source_session?: string;
id: number;
task_id: number;
node_id?: number;
kind: IntelligenceKind;
title: string;
content: string;
evidence: string;
confidence: "observed" | "inferred";
status: string;
asset_ids: number[];
worker: string;
created_at: string;
updated_at: string;
inherited: boolean;
}
export interface IntelligencePage {
credential_agent_enabled: boolean;
credential_traffic_binding: boolean;
items: TaskIntelligence[];
total: number;
counts: Record<IntelligenceKind, number>;
enabled: boolean;
}
+20 -8
View File
@@ -57,20 +57,32 @@ export async function copyText(text: string): Promise<boolean> {
// 继续走降级方案
}
}
const active = document.activeElement;
const selection = window.getSelection();
const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i)) : [];
const textarea = document.createElement("textarea");
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("aria-label", "复制内容");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
// Stay inside the active modal so its focus trap does not steal the selection.
const dialogs = document.querySelectorAll('[role="dialog"][data-state="open"]');
const container = active?.closest('[role="dialog"]') ?? dialogs.item(dialogs.length - 1) ?? document.body;
container.appendChild(textarea);
textarea.focus({ preventScroll: true });
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
return document.execCommand("copy");
} catch {
return false;
} finally {
textarea.remove();
if (active instanceof HTMLElement && active.isConnected) active.focus({ preventScroll: true });
if (selection && ranges.length) {
selection.removeAllRanges();
for (const range of ranges) selection.addRange(range);
}
}
}