mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #26 from RuoJi6/codex/task-intelligence
feat: 新增可选的任务情报自动记录与查看
This commit is contained in:
35 files changed
+3692
-58
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user