feat: prepare 0.3.3 worker llm agent and ui enhancements

This commit is contained in:
pphua committed 2026-08-21 23:55:43 +08:00
1 parent d3183dbbd2
commit 1bebdce7f1
28 files changed
+1331 -978

No files matched your search

@@ -35,7 +35,7 @@ export function MainContent({ children }: { children: ReactNode }) {
useEffect(() => {
api
.health()
.then((h) => setVersion(h.version ?? ""))
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
.catch(() => setVersion(""));
}, []);
+9 -3
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import {
ArrowUpIcon,
Bot,
ChevronDownIcon,
Loader2Icon,
@@ -12,7 +13,6 @@ import {
PinIcon,
PinOffIcon,
PlusIcon,
SendIcon,
Square,
Trash2Icon,
XIcon,
@@ -202,8 +202,14 @@ function Composer({
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}>
<SendIcon className="size-4" />
<Button
size="icon"
onClick={onSend}
disabled={disabled || (!value.trim() && atts.length === 0)}
title="发送消息"
aria-label="发送消息"
>
<ArrowUpIcon />
</Button>
)}
</div>
+8 -11
View File
@@ -10,7 +10,6 @@ import {
BugIcon,
ClockIcon,
NetworkIcon,
PlusIcon,
ShieldCheckIcon,
TargetIcon,
ZapIcon,
@@ -177,7 +176,7 @@ export default function DashboardPage() {
/* transient errors — next poll retries */
}
};
load();
void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
@@ -213,7 +212,7 @@ export default function DashboardPage() {
/* transient errors */
}
};
load();
void load();
const t = setInterval(load, 15000);
return () => {
alive = false;
@@ -461,17 +460,11 @@ export default function DashboardPage() {
return (
<div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */}
<div className="flex items-center justify-between">
<div>
<div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div>
<Link href="/function/tasks">
<button className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-90">
<PlusIcon className="size-3.5" />
新建任务
</button>
</Link>
</div>
{/* ── Row 1: 5 stat cards ── */}
@@ -575,6 +568,7 @@ export default function DashboardPage() {
] as const
).map(({ v, label }) => (
<button
type="button"
key={v}
onClick={() => setTokenVersion(v)}
title={
@@ -600,6 +594,7 @@ export default function DashboardPage() {
{/* Profile tabs */}
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={() => setTokenTab("all")}
className={cn(
"rounded-md px-2.5 py-1 text-[10px] font-medium transition-colors",
@@ -616,6 +611,7 @@ export default function DashboardPage() {
const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key;
return (
<button
type="button"
key={p.id}
onClick={() => setTokenTab(resolvedKey)}
className={cn(
@@ -745,6 +741,7 @@ export default function DashboardPage() {
] as const
).map(({ days, label }) => (
<button
type="button"
key={days}
onClick={() => setTokenDays(days)}
className={cn(
@@ -993,7 +990,7 @@ export default function DashboardPage() {
)}
</td>
<td className="px-4 py-3 tabular-nums">
{(t.in_flight ?? 0 > 0) ? (
{(t.in_flight ?? 0) > 0 ? (
<span className="font-semibold">{t.in_flight}</span>
) : (
<span className="text-muted-foreground">—</span>
+124 -192
View File
@@ -12,7 +12,6 @@ import {
LinkIcon,
type LucideIcon,
NetworkIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
SmartphoneIcon,
@@ -46,7 +45,6 @@ import {
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
@@ -59,7 +57,9 @@ import {
} from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { MAX_COMPANY_SCOPE_RULES, type ParsedCompanyScopeText, parseCompanyScopeText } from "@/lib/company-scope";
import type { Asset, Company, CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -1254,11 +1254,13 @@ function AssetCard({
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
<SelectGroup>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="tabular-nums">
@@ -1319,171 +1321,94 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
);
}
const COMPANY_SCOPE_KINDS: { value: CompanyScopeKind; label: string; placeholder: string }[] = [
{ value: "domain", label: "域名", placeholder: "example.com" },
{ value: "ip", label: "IP", placeholder: "203.0.113.10" },
{ value: "cidr", label: "CIDR", placeholder: "198.51.100.0/24" },
{ value: "icp", label: "ICP", placeholder: "京ICP备12345678号-1" },
{ value: "keyword", label: "企业关键词", placeholder: "Acme / 产品品牌名" },
];
const COMPANY_SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
domain: "域名",
ip: "IP",
cidr: "CIDR",
icp: "ICP",
keyword: "关键词",
};
const MAX_COMPANY_SCOPE_RULES = 256;
const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
type EditableScopeRule = CompanyScopeRule & { key: string };
let nextScopeRuleKey = 0;
function editableScopeRule(kind: CompanyScopeKind = "domain", value = ""): EditableScopeRule {
nextScopeRuleKey += 1;
return { kind, value, key: `company-scope-${nextScopeRuleKey}` };
}
function isIPv4(value: string): boolean {
const parts = value.split(".");
return (
parts.length === 4 &&
parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) >= 0 && Number(part) <= 255)
);
}
function isIPv6(value: string): boolean {
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
const halves = value.split("::");
if (halves.length > 2) return false;
const countSegments = (half: string): number | null => {
if (!half) return 0;
const segments = half.split(":");
let count = 0;
for (const [index, segment] of segments.entries()) {
if (segment.includes(".")) {
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
count += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
count++;
}
}
return count;
};
const left = countSegments(halves[0]);
const right = countSegments(halves[1] ?? "");
if (left === null || right === null) return false;
return halves.length === 2 ? left + right < 8 : left === 8;
}
function ipVersion(value: string): 4 | 6 | null {
if (isIPv4(value)) return 4;
if (isIPv6(value)) return 6;
return null;
}
function scopeRuleError(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (!value) return "请填写范围值";
if (rule.kind === "domain" && (!value.includes(".") || /\s/.test(value))) return "请输入有效域名";
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
if (rule.kind === "cidr") {
const separator = value.lastIndexOf("/");
if (separator <= 0) return "请输入 CIDR 网段";
const address = value.slice(0, separator);
const prefixText = value.slice(separator + 1);
const version = ipVersion(address);
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
const prefix = Number(prefixText);
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
}
return "";
}
function ScopeRulesEditor({
function ScopeTextEditor({
id,
value,
onValueChange,
parsed,
}: {
value: EditableScopeRule[];
onValueChange: (value: EditableScopeRule[]) => void;
id: string;
value: string;
onValueChange: (value: string) => void;
parsed: ParsedCompanyScopeText;
}) {
const update = (index: number, patch: Partial<EditableScopeRule>) => {
onValueChange(value.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
};
const counts = React.useMemo(() => {
const result = new Map<CompanyScopeKind, number>();
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
return result;
}, [parsed.rules]);
return (
<div className="flex flex-col gap-3">
{value.map((rule, index) => {
const meta = COMPANY_SCOPE_KINDS.find((item) => item.value === rule.kind) ?? COMPANY_SCOPE_KINDS[0];
// Empty rows are omitted on submit, so the initial optional row should
// not appear invalid before the user enters a value.
const error = rule.value.trim() ? scopeRuleError(rule) : "";
return (
<Field key={rule.key} data-invalid={!!error}>
<div className="flex items-start gap-2">
<Select value={rule.kind} onValueChange={(kind) => update(index, { kind: kind as CompanyScopeKind })}>
<SelectTrigger className="w-32 shrink-0" aria-label={`范围 ${index + 1} 类型`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COMPANY_SCOPE_KINDS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
value={rule.value}
placeholder={meta.placeholder}
aria-label={`范围 ${index + 1} 内容`}
aria-invalid={!!error}
maxLength={MAX_COMPANY_SCOPE_VALUE_LENGTH}
onChange={(event) => update(index, { value: event.target.value })}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`删除范围 ${index + 1}`}
disabled={value.length === 1}
onClick={() => onValueChange(value.filter((_, i) => i !== index))}
>
<Trash2Icon />
</Button>
</div>
{rule.value && error ? <FieldError>{error}</FieldError> : null}
</Field>
);
})}
<Button
type="button"
variant="outline"
className="self-start"
disabled={value.length >= MAX_COMPANY_SCOPE_RULES}
title={value.length >= MAX_COMPANY_SCOPE_RULES ? `每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条范围` : undefined}
onClick={() => onValueChange([...value, editableScopeRule()])}
>
<PlusIcon data-icon="inline-start" />
添加范围
</Button>
</div>
<Field data-invalid={parsed.errors.length > 0}>
<FieldLabel htmlFor={id}>资产范围</FieldLabel>
<FieldDescription>每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。</FieldDescription>
<Textarea
id={id}
rows={8}
value={value}
aria-invalid={parsed.errors.length > 0}
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
className="min-h-36 resize-y font-mono text-sm"
onChange={(event) => onValueChange(event.target.value)}
/>
{parsed.rules.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span>已识别 {parsed.rules.length} 条</span>
{Object.entries(COMPANY_SCOPE_KIND_LABELS).map(([kind, label]) => {
const count = counts.get(kind as CompanyScopeKind) ?? 0;
return count > 0 ? (
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
{label} {count}
</Badge>
) : null;
})}
</div>
)}
{parsed.errors.length > 0 && (
<FieldError>
{parsed.errors.slice(0, 5).map((item) => (
<span key={`${item.line}-${item.error}`} className="block">
第 {item.line} 行:{item.error}
</span>
))}
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
</FieldError>
)}
</Field>
);
}
function savedScopeRules(company: Company): EditableScopeRule[] {
const rules = (company.scope ?? []).map((scope) => editableScopeRule(scope.kind, scope.raw));
return rules.length > 0 ? rules : [editableScopeRule()];
function savedScopeRules(company: Company): CompanyScopeRule[] {
return (company.scope ?? [])
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
.filter((scope) => scope.value);
}
function savedScopeText(company: Company): string {
return savedScopeRules(company)
.map((scope) => scope.value)
.join("\n");
}
// 新增企业使用与任务、LLM 编辑一致的右侧抽屉。
function CompanyDialog({ onSaved }: { onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [busy, setBusy] = React.useState(false);
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
React.useEffect(() => {
if (!open) return;
setName("");
setScope([editableScopeRule()]);
setScopeText("");
}, [open]);
const submit = async () => {
@@ -1491,16 +1416,13 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
toast.error("请填写企业名称");
return;
}
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.createCompany(name.trim(), rules);
const res = await api.createCompany(name.trim(), parsedScope.rules);
const added = res.scope_added ?? 0;
const invalid = res.scope_invalid ?? 0;
if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`);
@@ -1539,18 +1461,14 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>资产范围</FieldLabel>
<FieldDescription>支持域名、IP、CIDR、ICP 备案和企业名称关键词。</FieldDescription>
<ScopeRulesEditor value={scope} onValueChange={setScope} />
</Field>
<ScopeTextEditor id="cn-scope" value={scopeText} onValueChange={setScopeText} parsed={parsedScope} />
</FieldGroup>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>
<Button onClick={submit} disabled={busy || !name.trim() || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "保存"}
</Button>
</SheetFooter>
@@ -1562,27 +1480,29 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
// 编辑(覆盖)资产范围弹窗
function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const preservedScopeRules = React.useMemo(() => savedScopeRules(company), [company]);
const parsedScope = React.useMemo(
() => parseCompanyScopeText(scopeText, { preservedRules: preservedScopeRules }),
[preservedScopeRules, scopeText],
);
React.useEffect(() => {
if (!open) return;
setScope(savedScopeRules(company));
setScopeText(savedScopeText(company));
setReason("");
}, [open, company]);
const submit = async () => {
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.updateCompanyScope(company.id, rules, reason);
const res = await api.updateCompanyScope(company.id, parsedScope.rules, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`范围已更新,共 ${res.added} 条`);
@@ -1609,23 +1529,28 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
编辑后将替换全部现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<ScopeRulesEditor value={scope} onValueChange={setScope} />
<div className="grid gap-1.5">
<Label htmlFor="es-reason">归属依据(可选)</Label>
<FieldGroup className="py-2">
<ScopeTextEditor
id={`edit-company-scope-${company.id}`}
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
/>
<Field>
<FieldLabel htmlFor="es-reason">归属依据(可选)</FieldLabel>
<Input
id="es-reason"
placeholder="如 证书 / whois / ASN 佐证"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
</div>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy}>
<Button onClick={submit} disabled={busy || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "覆盖保存"}
</Button>
</DialogFooter>
@@ -1637,31 +1562,33 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
// 追加资产范围弹窗
function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const remainingScopeRules = Math.max(0, MAX_COMPANY_SCOPE_RULES - (company.scope?.length || 0));
const parsedScope = React.useMemo(
() => parseCompanyScopeText(scopeText, { maxRules: remainingScopeRules }),
[remainingScopeRules, scopeText],
);
React.useEffect(() => {
if (!open) return;
setScope([editableScopeRule()]);
setScopeText("");
setReason("");
}, [open]);
const submit = async () => {
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.length === 0) {
if (parsedScope.rules.length === 0) {
toast.error("请填写要追加的范围");
return;
}
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.addCompanyScope(company.id, rules, reason);
const res = await api.addCompanyScope(company.id, parsedScope.rules, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`已追加 ${res.added} 条范围`);
@@ -1686,23 +1613,28 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
<DialogTitle>追加资产范围 · {company.name}</DialogTitle>
<DialogDescription>新范围会追加到现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<ScopeRulesEditor value={scope} onValueChange={setScope} />
<div className="grid gap-1.5">
<Label htmlFor="as-reason">归属依据(可选)</Label>
<FieldGroup className="py-2">
<ScopeTextEditor
id={`append-company-scope-${company.id}`}
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
/>
<Field>
<FieldLabel htmlFor="as-reason">归属依据(可选)</FieldLabel>
<Input
id="as-reason"
placeholder="如 证书 / whois / ASN 佐证"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
</div>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy}>
<Button onClick={submit} disabled={busy || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "追加"}
</Button>
</DialogFooter>
+87 -101
View File
@@ -1,37 +1,19 @@
"use client";
import * as React from "react";
import {
TerminalIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { CommandRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -50,7 +32,9 @@ function toolInput(raw: string): string {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
return JSON.stringify(obj, null, 2);
} catch { /* not JSON */ }
} catch {
/* not JSON */
}
return raw;
}
@@ -75,7 +59,7 @@ export default function CommandsPage() {
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Inline detail panel (like traffic page, not a dialog)
// Selected execution is rendered in a right-side detail sheet.
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
// Debounce search input.
@@ -85,6 +69,7 @@ export default function CommandsPage() {
}, [query]);
// Reset page on filter change.
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
React.useEffect(() => {
setPage(0);
}, [queryQ, taskFilter, size]);
@@ -100,9 +85,15 @@ export default function CommandsPage() {
setCommands(r.commands ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.catch(() => {
if (!alive) return;
setCommands([]);
setTotal(0);
})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
return () => {
alive = false;
};
}, [page, size, queryQ, taskFilter]);
const totalPages = Math.max(1, Math.ceil(total / size));
@@ -178,8 +169,8 @@ export default function CommandsPage() {
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
{/* History table */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
@@ -210,18 +201,13 @@ export default function CommandsPage() {
commands.map((cmd) => (
<TableRow
key={cmd.id}
className={cn(
"cursor-pointer",
selected?.id === cmd.id && "bg-accent hover:bg-accent",
)}
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(cmd)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(cmd.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
#{cmd.exploration_id}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{cmd.worker || "-"}
@@ -239,9 +225,13 @@ export default function CommandsPage() {
</TableCell>
<TableCell>
{cmd.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
<Badge variant="destructive" className="text-xs">
失败
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
<Badge variant="secondary" className="text-xs text-emerald-600">
成功
</Badge>
)}
</TableCell>
</TableRow>
@@ -251,67 +241,63 @@ export default function CommandsPage() {
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.created_at)}
</span>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Input / Output split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输入 Input
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{toolInput(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输出 Output
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</Card>
)}
</div>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="pr-8">工具执行详情</SheetTitle>
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">
失败
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">
成功
</Badge>
)}
</div>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输入 Input</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
{toolInput(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输出 Output</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre
className={cn(
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}
>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
+29 -21
View File
@@ -6,12 +6,16 @@ import Link from "next/link";
import {
ArrowUpRightIcon,
BugIcon,
ChevronRightIcon,
ClockIcon,
DownloadIcon,
FileTextIcon,
FlaskConicalIcon,
InfoIcon,
ShieldAlertIcon,
Trash2Icon,
TriangleAlertIcon,
} from "lucide-react";
import { toast } from "sonner";
@@ -30,7 +34,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@@ -529,13 +532,13 @@ export default function FindingsPage() {
}
}
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: stats.total },
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
{ label: "严重", value: stats.critical, tone: "text-rose-600" },
{ label: "高危", value: stats.high, tone: "text-red-500" },
{ label: "中危", value: stats.medium, tone: "text-amber-500" },
{ label: "低危", value: stats.low, tone: "text-slate-500" },
const statCards = [
{ label: "发现总数", value: stats.total, icon: BugIcon },
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
];
return (
@@ -546,14 +549,20 @@ export default function FindingsPage() {
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{statCards.map((s) => (
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>{s.value}</CardTitle>
</CardHeader>
</Card>
))}
{statCards.map((stat) => {
const StatIcon = stat.icon;
return (
<Card key={stat.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{stat.label}</CardDescription>
<CardTitle className={cn("flex items-center gap-2 text-2xl tabular-nums", stat.tone)}>
<StatIcon className="size-5" aria-hidden="true" />
{stat.value}
</CardTitle>
</CardHeader>
</Card>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -624,7 +633,10 @@ export default function FindingsPage() {
<span className="max-w-[14rem] truncate" title={label}>
{label}
</span>
<span className="text-muted-foreground tabular-nums">{t.count}</span>
<span className="inline-flex items-center gap-1 text-muted-foreground tabular-nums">
<BugIcon className="size-3.5" aria-hidden="true" />
{t.count}
</span>
</span>
</SelectItem>
);
@@ -646,7 +658,6 @@ export default function FindingsPage() {
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">共 {total} 条</span>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
@@ -712,9 +723,6 @@ export default function FindingsPage() {
</span>
);
})}
<Badge variant="secondary" className="tabular-nums">
共 {group.count} 条
</Badge>
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
{group.task_id !== null && (
<Button size="icon-sm" variant="ghost" asChild>
@@ -3,6 +3,7 @@
import * as React from "react";
import {
ArrowUpIcon,
BrainIcon,
CircleCheckIcon,
CircleSlashIcon,
@@ -15,7 +16,6 @@ import {
PlayIcon,
RadioIcon,
RotateCwIcon,
SendIcon,
ShieldAlertIcon,
SquareIcon,
Trash2Icon,
@@ -40,7 +40,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -48,7 +47,6 @@ import { api, sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
BatchControlItem,
ChatAttachment,
InterceptApprovalRow,
Session,
@@ -163,6 +161,36 @@ function fmtTokens(n: number): string {
return String(n);
}
const TokenMetrics = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span"> & {
input: number;
cache: number;
output: number;
labels?: "short" | "long";
}
>(({ input, cache, output, labels = "short", className, ...props }, ref) => {
const names = labels === "short" ? ["入", "缓", "出"] : ["input", "cache", "output"];
const values = [input, cache, output];
return (
<span
ref={ref}
className={cn("inline-flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground", className)}
{...props}
>
{values.map((value, index) => (
<React.Fragment key={names[index]}>
<span>{names[index]}</span>
<Badge variant="secondary" className="h-5 px-1.5 font-mono tabular-nums">
{fmtTokens(value)}
</Badge>
</React.Fragment>
))}
</span>
);
});
TokenMetrics.displayName = "TokenMetrics";
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
@@ -277,12 +305,6 @@ function SessionItem({
onResume,
onCancel,
controlling,
resolution,
tokens,
selectable,
selected,
onSelectedChange,
selectionDisabled,
}: {
s: Session;
active: boolean;
@@ -294,16 +316,7 @@ function SessionItem({
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
resolution?: TaskLLMResolution;
tokens?: TokenTotal;
selectable?: boolean;
selected?: boolean;
onSelectedChange?: (checked: boolean) => void;
selectionDisabled?: boolean;
}) {
const tokenCount = tokens
? tokens.input_tokens + tokens.output_tokens + tokens.cache_read_tokens + tokens.cache_write_tokens
: 0;
const icon =
s.role === "worker" ? (
statusIcon(s.status)
@@ -319,16 +332,6 @@ function SessionItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{selectable && (
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelectedChange?.(value === true)}
onClick={(event) => event.stopPropagation()}
disabled={selectionDisabled}
className="ml-1 shrink-0"
aria-label={`选择 Worker ${s.intent_id}`}
/>
)}
<button
type="button"
onClick={onClick}
@@ -345,32 +348,7 @@ function SessionItem({
来源 #{s.source_task_id}
</Badge>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{resolution && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="min-w-0 max-w-24 shrink-0">
<span className="min-w-0 truncate">{resolution.available ? resolution.model : "模型不可用"}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs [overflow-wrap:anywhere]">
{resolution.available
? `${resolution.name} / ${resolution.format} / ${resolution.model}`
: resolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{tokens && tokenCount > 0 && s.role === "worker" && !s.inherited && (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="shrink-0 font-mono tabular-nums">{fmtTokens(tokenCount)}</Badge>
</TooltipTrigger>
<TooltipContent side="right">
输入 {tokens.input_tokens.toLocaleString()} · 输出 {tokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{tokens.cache_read_tokens.toLocaleString()} · 缓存写入 {tokens.cache_write_tokens.toLocaleString()}
</TooltipContent>
</Tooltip>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">{displayTitle}</span>
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
@@ -446,8 +424,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [selectedIntentIds, setSelectedIntentIds] = React.useState<Set<string>>(() => new Set());
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
@@ -478,7 +454,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const controlWorker = React.useCallback(
async (session: Session, action: "pause" | "resume" | "cancel") => {
if (!session.intent_id || session.inherited || controllingIntent || batchControlling) return;
if (!session.intent_id || session.inherited || controllingIntent) return;
setControllingIntent(session.intent_id);
try {
const result = await api.controlIntent(taskId, session.intent_id, action);
@@ -505,7 +481,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setCancelIntent(null);
}
},
[activeId, batchControlling, controllingIntent, patchIntentState, taskId],
[activeId, controllingIntent, patchIntentState, taskId],
);
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
@@ -758,7 +734,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setFirstIntentsHasMore(false);
setOlderIntentsHasMore(false);
setHasLoadedOlderIntentsPage(false);
setSelectedIntentIds(new Set());
setTaskTokens(null);
setSessionTokens({});
setLLMResolutions(null);
@@ -978,77 +953,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]);
const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore;
const selectableIntentIds = React.useMemo(
() =>
allIntents
.filter((node) => !node.inherited && (node.state === "running" || node.state === "paused"))
.map((node) => node.id),
[allIntents],
);
React.useEffect(() => {
const allowed = new Set(selectableIntentIds);
setSelectedIntentIds((current) => {
const next = new Set([...current].filter((id) => allowed.has(id)));
return next.size === current.size ? current : next;
});
}, [selectableIntentIds]);
const toggleIntentSelection = React.useCallback((id: string, checked: boolean) => {
setSelectedIntentIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}, []);
const batchControlWorkers = React.useCallback(
async (action: "pause" | "resume") => {
const requiredState = action === "pause" ? "running" : "paused";
const ids = allIntents
.filter((node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === requiredState)
.map((node) => node.id);
if (ids.length === 0 || batchControlling) return;
setBatchControlling(action);
try {
const items: BatchControlItem[] = [];
const requestErrors: Error[] = [];
for (let offset = 0; offset < ids.length; offset += 100) {
const chunk = ids.slice(offset, offset + 100);
try {
const response = await api.controlIntentsBatch(taskId, chunk, action);
items.push(...response.items);
} catch (error) {
requestErrors.push(error as Error);
items.push(...chunk.map((id) => ({ id, ok: false, error: (error as Error).message })));
}
}
const succeeded = items.filter((item) => item.ok);
const failed = items.length - succeeded.length;
for (const item of succeeded) {
patchIntentState(item.id, item.state ?? (action === "pause" ? "paused" : "open"));
}
setSelectedIntentIds((current) => {
const next = new Set(current);
for (const item of succeeded) next.delete(item.id);
return next;
});
if (succeeded.length > 0)
toast.success(`已${action === "pause" ? "暂停" : "继续"} ${succeeded.length} 个 Worker`);
if (requestErrors.length > 0) {
toast.error(`${requestErrors.length} 组批量请求失败,${failed} 个 Worker 未执行`);
} else if (failed > 0) {
toast.warning(`${failed} 个 Worker 状态已变化,未执行`);
}
} catch (error) {
toast.error(`批量操作失败:${(error as Error).message}`);
} finally {
setBatchControlling(null);
}
},
[allIntents, batchControlling, patchIntentState, selectedIntentIds, taskId],
);
// For each worker session (intent), derive the display title from the intent
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
const sessionMeta = React.useMemo(() => {
@@ -1217,7 +1121,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
i: activeTokens.input_tokens,
o: activeTokens.output_tokens,
cr: activeTokens.cache_read_tokens,
cw: activeTokens.cache_write_tokens,
any:
activeTokens.input_tokens +
activeTokens.output_tokens +
@@ -1331,25 +1234,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const mainLoaded = !!store.main?.loaded;
// What the transcript pane should show for the active session.
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
const selectedPauseCount = allIntents.filter(
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "running",
).length;
const selectedResumeCount = allIntents.filter(
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "paused",
).length;
const selectedSelectableCount = selectableIntentIds.filter((id) => selectedIntentIds.has(id)).length;
let allSelectableChecked: boolean | "indeterminate" = false;
if (selectableIntentIds.length > 0 && selectedSelectableCount === selectableIntentIds.length) {
allSelectableChecked = true;
} else if (selectedSelectableCount > 0) {
allSelectableChecked = "indeterminate";
}
const resolutionForSession = (session: Session): TaskLLMResolution | undefined => {
if (!llmResolutions || session.inherited || session.role === "system") return undefined;
if (session.role === "mainagent") return llmResolutions.mainagent;
if (session.role === "planner") return llmResolutions.planner;
return llmResolutions.worker;
};
const activeResolution = resolutionForSession(active);
return (
<TooltipProvider delayDuration={300}>
@@ -1384,10 +1275,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
{taskTokens && (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="mt-2 w-full min-w-0 justify-start font-mono tabular-nums">
任务总计 · 入 {fmtTokens(taskTokens.input_tokens)} · 缓 {fmtTokens(taskTokens.cache_read_tokens)} ·
出 {fmtTokens(taskTokens.output_tokens)}
</Badge>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>任务总计</span>
<span>·</span>
<TokenMetrics
input={taskTokens.input_tokens}
cache={taskTokens.cache_read_tokens}
output={taskTokens.output_tokens}
/>
</div>
</TooltipTrigger>
<TooltipContent>
输入 {taskTokens.input_tokens.toLocaleString()} · 输出 {taskTokens.output_tokens.toLocaleString()} ·
@@ -1396,35 +1292,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</TooltipContent>
</Tooltip>
)}
{selectedIntentIds.size > 0 && (
<div className="mt-2 flex items-center gap-1">
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
已选 {selectedIntentIds.size} 个 Worker
</span>
<Button
type="button"
variant="outline"
size="icon-xs"
disabled={selectedPauseCount === 0 || batchControlling !== null}
onClick={() => void batchControlWorkers("pause")}
title={`暂停 ${selectedPauseCount} 个运行中 Worker`}
aria-label="批量暂停 Worker"
>
{batchControlling === "pause" ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
disabled={selectedResumeCount === 0 || batchControlling !== null}
onClick={() => void batchControlWorkers("resume")}
title={`继续 ${selectedResumeCount} 个已暂停 Worker`}
aria-label="批量继续 Worker"
>
{batchControlling === "resume" ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
</Button>
</div>
)}
</div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2">
@@ -1435,23 +1302,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
return (
<div key={role} className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
{role === "worker" && selectableIntentIds.length > 0 && (
<Checkbox
checked={allSelectableChecked}
onCheckedChange={(value) => {
const checked = value === true;
setSelectedIntentIds((current) => {
const next = new Set(current);
for (const id of selectableIntentIds) {
if (checked) next.add(id);
else next.delete(id);
}
return next;
});
}}
aria-label="选择全部可控制 Worker"
/>
)}
<Meta.icon className="size-3.5" />
{Meta.label}
</div>
@@ -1466,19 +1316,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
controlling={batchControlling !== null || controllingIntent === s.intent_id}
resolution={resolutionForSession(s)}
tokens={tokenForSession(keyForSession(s))}
selectable={
s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused")
}
selected={selectedIntentIds.has(s.intent_id ?? "")}
selectionDisabled={batchControlling !== null}
onSelectedChange={(checked) => s.intent_id && toggleIntentSelection(s.intent_id, checked)}
controlling={controllingIntent === s.intent_id}
onPause={() => void controlWorker(s, "pause")}
onResume={() => void controlWorker(s, "resume")}
onCancel={() => {
if (!batchControlling) setCancelIntent(s);
setCancelIntent(s);
}}
/>
);
@@ -1529,6 +1371,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
titleEl
);
})()}
{activeResolution && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="max-w-28 shrink-0 font-mono font-normal"
aria-label={activeResolution.available ? `当前模型:${activeResolution.model}` : "模型不可用"}
>
<span className="truncate">
{activeResolution.available ? activeResolution.model : "模型不可用"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
{activeResolution.available
? `${activeResolution.name} / ${activeResolution.format} / ${activeResolution.model}`
: activeResolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{active.inherited && active.source_task_id && (
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
)}
@@ -1545,9 +1407,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
)}
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
{tokenTotal.any && (
<span className="min-w-0 truncate" title="input / cache(read) / output tokens">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<TokenMetrics input={tokenTotal.i} cache={tokenTotal.cr} output={tokenTotal.o} labels="long" />
</TooltipTrigger>
<TooltipContent>
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
{activeTokens.cache_write_tokens.toLocaleString()}
</TooltipContent>
</Tooltip>
)}
{runDuration != null && (
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
@@ -1673,7 +1543,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
+244 -165
View File
@@ -1,15 +1,15 @@
"use client";
import * as React from "react";
import {
RadioTowerIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XIcon,
Loader2Icon,
Trash2Icon,
ListChecksIcon,
Loader2Icon,
RadioTowerIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import {
@@ -22,35 +22,19 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -66,7 +50,7 @@ function fmtBytes(n: number) {
if (n <= 0) return "0 B";
const units = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
const v = n / Math.pow(1024, i);
const v = n / 1024 ** i;
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
@@ -78,6 +62,141 @@ function statusTone(status: number) {
return "text-muted-foreground";
}
function MethodBadge({ method }: { method: string }) {
return <Badge className="shrink-0 font-mono">{method}</Badge>;
}
// Older captures may predate Host persistence because net/http keeps Host
// outside Request.Header. Fill it for display while newly recorded traffic is
// fixed at the recorder layer as well.
function requestWithHost(raw: string, exchange: TrafficExchange): string {
if (!raw.trim() || /^host\s*:/im.test(raw)) return raw;
let host = exchange.host;
try {
host = new URL(exchange.url).host || host;
} catch {
// Relative or legacy URLs fall back to the indexed host.
}
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
const firstLineEnd = raw.indexOf(newline);
if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`;
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
}
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
if (request) {
return (
<>
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return line;
return (
<>
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
function JsonBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
let className = "text-chart-4";
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
else if (match[3]) className = "text-chart-3";
parts.push(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function MarkupBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
parts.push(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function HighlightedBody({ body }: { body: string }) {
const trimmed = body.trimStart();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return <JsonBody body={body} />;
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
return body;
}
function HttpCodeBlock({ raw }: { raw: string }) {
const value = raw || "(空)";
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
const headerLines = separator >= 0 ? lines.slice(0, separator) : lines;
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
return (
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
<code>
{headerLines.map((line, index) => (
<React.Fragment key={`${index}-${line}`}>
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
{index < headerLines.length - 1 ? "\n" : null}
</React.Fragment>
))}
{separator >= 0 && (
<>
{"\n\n"}
<HighlightedBody body={body} />
</>
)}
</code>
</pre>
);
}
// Fixed method set (server filters exact-match); avoids deriving options from a
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
@@ -116,12 +235,14 @@ export default function TrafficPage() {
}, [query]);
// Any filter/size change resets to the first page.
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
React.useEffect(() => {
setPage(0);
}, [hostQ, queryQ, method, size]);
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
// through history isn't yanked out from under the user.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
React.useEffect(() => {
let alive = true;
const load = () => {
@@ -130,13 +251,17 @@ export default function TrafficPage() {
.then((r) => {
if (alive) setTraffic(r);
})
.catch(() => {});
.catch(() => {
// Keep the last successful snapshot during transient refresh failures.
});
api
.trafficHosts()
.then((r) => {
if (alive) setHosts(r.hosts ?? []);
})
.catch(() => {});
.catch(() => {
// Keep the last successful host list during transient refresh failures.
});
};
load();
const t = setInterval(() => {
@@ -155,22 +280,21 @@ export default function TrafficPage() {
const confirmDelete = () => {
setDeleting(true);
const p =
deleteMode === "selected"
? api.trafficDeleteHosts(selectedHosts)
: api.trafficDeleteHost(hostQ);
const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ);
p.then(() => {
setDeleteMode(null);
setSelected(null);
setDetail(null);
if (deleteMode === "selected") {
setSelectedHosts([]);
setPickerOpen(false);
}
setPage(0);
setReloadTick((t) => t + 1);
setDeleteMode(null);
setSelected(null);
setDetail(null);
if (deleteMode === "selected") {
setSelectedHosts([]);
setPickerOpen(false);
}
setPage(0);
setReloadTick((t) => t + 1);
})
.catch(() => {
// Keep the confirmation open so the user can retry a failed deletion.
})
.catch(() => {})
.finally(() => setDeleting(false));
};
@@ -210,9 +334,7 @@ export default function TrafficPage() {
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">
全局录制代理 · 所有 HTTP 往来
</p>
<p className="text-muted-foreground text-sm">全局录制代理 · 所有 HTTP 往来</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
@@ -226,11 +348,7 @@ export default function TrafficPage() {
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && (
<span className="font-mono text-xs text-muted-foreground">
{traffic.proxy}
</span>
)}
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
@@ -266,29 +384,25 @@ export default function TrafficPage() {
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
暂无流量记录
</div>
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
) : (
hosts.map((h) => (
hosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
>
<Checkbox
id={`traffic-host-${index}`}
checked={selectedHosts.includes(h.host)}
onCheckedChange={() =>
setSelectedHosts((prev) =>
prev.includes(h.host)
? prev.filter((x) => x !== h.host)
: [...prev, h.host],
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
)
}
/>
<span className="truncate font-mono">{h.host}</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
{h.count}
</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
</label>
))
)}
@@ -310,12 +424,7 @@ export default function TrafficPage() {
</PopoverContent>
</Popover>
<div className="relative w-48">
<Input
placeholder="host…"
value={host}
onChange={(e) => setHost(e.target.value)}
className="h-8"
/>
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
</div>
<Button
variant="destructive"
@@ -391,8 +500,8 @@ export default function TrafficPage() {
</div>
</div>
{/* History table + detail (Burp-style split) */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
{/* History table */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
@@ -411,50 +520,29 @@ export default function TrafficPage() {
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn(
"cursor-pointer",
selected?.id === e.id && "bg-accent hover:bg-accent",
)}
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(e)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(e.ts)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{e.method}
</Badge>
<MethodBadge method={e.method} />
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">
{e.url}
</span>
<span className="block truncate font-mono text-xs">{e.url}</span>
</TableCell>
<TableCell>
<span
className={cn(
"font-mono text-xs font-semibold tabular-nums",
statusTone(e.status),
)}
>
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.content_type}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{fmtBytes(e.resp_len)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
<TableCell colSpan={7} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
@@ -463,66 +551,60 @@ export default function TrafficPage() {
</Table>
</div>
</Card>
{/* Raw request/response pane */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="font-mono text-xs">
{selected.method}
</Badge>
<span className="truncate font-mono text-xs">
{selected.host}
{selected.url}
</span>
<span
className={cn(
"ml-auto font-mono text-xs font-semibold tabular-nums",
statusTone(selected.status),
)}
>
{selected.status}
</span>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
{(
[
["请求 Request", detail?.req],
["响应 Response", detail?.resp],
] as const
).map(([label, body]) => (
<div key={label} className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
{label}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{body || "(空)"}
</pre>
)}
</div>
</div>
))}
</div>
</Card>
)}
</div>
<AlertDialog open={deleteMode !== null} onOpenChange={(o) => { if (!o) setDeleteMode(null); }}>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<div className="flex items-center gap-2 pr-8">
<MethodBadge method={selected.method} />
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
{selected.status}
</Badge>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
</SheetHeader>
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
<TabsContent value="request" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
)}
</TabsContent>
<TabsContent value="response" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={detail?.resp ?? ""} />
)}
</TabsContent>
</Tabs>
</>
)}
</SheetContent>
</Sheet>
<AlertDialog
open={deleteMode !== null}
onOpenChange={(o) => {
if (!o) setDeleteMode(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -533,9 +615,7 @@ export default function TrafficPage() {
<AlertDialogDescription>
{deleteMode === "selected" ? (
<>
将永久删除{" "}
<span className="font-semibold tabular-nums">{selectedHosts.length}</span>{" "}
个目标(
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
<span className="font-mono">
{selectedHosts.slice(0, 3).join("、")}
{selectedHosts.length > 3 ? "…" : ""}
@@ -544,8 +624,7 @@ export default function TrafficPage() {
</>
) : (
<>
将永久删除 host 包含{" "}
<span className="font-mono font-semibold">{hostQ}</span>{" "}
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
+52 -48
View File
@@ -24,17 +24,10 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
@@ -172,19 +165,19 @@ function TaskTemplateManager({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid max-h-[90svh] max-w-4xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle>任务模板管理</DialogTitle>
<DialogDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)] md:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 md:border-r md:border-b-0">
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务模板管理</SheetTitle>
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
新建模板
</Button>
<ScrollArea className="mt-2 max-h-44 md:max-h-[27rem]">
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
{templates.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
@@ -206,7 +199,7 @@ function TaskTemplateManager({
</div>
</ScrollArea>
</div>
<ScrollArea className="max-h-[58svh]">
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
<Field>
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
@@ -241,7 +234,7 @@ function TaskTemplateManager({
</FieldGroup>
</ScrollArea>
</div>
<DialogFooter className="border-t px-6 py-4">
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedID != null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
<Trash2Icon data-icon="inline-start" />
@@ -255,9 +248,9 @@ function TaskTemplateManager({
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -303,6 +296,7 @@ export function TaskTemplateControls({
}: TaskTemplateControlsProps) {
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
@@ -323,25 +317,28 @@ export function TaskTemplateControls({
void loadTemplates();
}, [loadTemplates]);
const templateIDs = React.useMemo(() => templates.map((template) => String(template.id)), [templates]);
const templatesByID = React.useMemo(
() => new Map(templates.map((template) => [String(template.id), template])),
[templates],
const selectedTemplate = React.useMemo(
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
[selectedTemplateID, templates],
);
React.useEffect(() => {
setTemplateInputValue(selectedTemplate?.name ?? "");
}, [selectedTemplate]);
const applyTemplate = (template: TaskTemplate) => {
onApply(template);
onSelectedTemplateIDChange(template.id);
setPendingTemplate(null);
};
const chooseTemplate = (value: string | null) => {
if (!value) {
const chooseTemplate = (template: TaskTemplate | null) => {
if (!template) {
setTemplateInputValue("");
onSelectedTemplateIDChange(null);
return;
}
const template = templatesByID.get(value);
if (!template) return;
setTemplateInputValue(template.name);
const hasContent = description.trim() !== "" || goal.trim() !== "";
const changesContent = description !== template.description || goal !== template.goal;
if (hasContent && changesContent) {
@@ -391,9 +388,12 @@ export function TaskTemplateControls({
</div>
</div>
<Combobox
items={templateIDs}
itemToStringValue={(id) => templatesByID.get(id)?.name ?? id}
value={selectedTemplateID == null ? null : String(selectedTemplateID)}
items={templates}
itemToStringLabel={(template) => template.name}
itemToStringValue={(template) => String(template.id)}
inputValue={templateInputValue}
onInputValueChange={(value) => setTemplateInputValue(value)}
value={selectedTemplate}
onValueChange={chooseTemplate}
>
<ComboboxInput
@@ -406,27 +406,31 @@ export function TaskTemplateControls({
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
<ComboboxList>
{(id) => {
const template = templatesByID.get(id);
return (
<ComboboxItem key={id} value={id}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template?.name ?? `模板 #${id}`}</p>
{template?.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
);
}}
{(template) => (
<ComboboxItem key={template.id} value={template}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template.name}</p>
{template.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
</Field>
<AlertDialog open={pendingTemplate != null} onOpenChange={(open) => !open && setPendingTemplate(null)}>
<AlertDialog
open={pendingTemplate != null}
onOpenChange={(open) => {
if (open) return;
setPendingTemplate(null);
setTemplateInputValue(selectedTemplate?.name ?? "");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
-7
View File
@@ -11,7 +11,6 @@ import type {
AgentDetail,
AgentTrigger,
Asset,
AssetNode,
Audit,
BatchControlItem,
ChatAttachment,
@@ -56,7 +55,6 @@ import type {
SSTask,
Stats,
Task,
TaskAssetView,
TaskLLMResolutions,
TaskNode,
TaskScopeRow,
@@ -230,11 +228,6 @@ export const api = {
state: "paused" | "open" | "cancelled";
deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
controlIntentsBatch: (taskId: string, intentIds: string[], action: "pause" | "resume") =>
post<{ items: BatchControlItem[] }>(`/tasks/${taskId}/intents/control/batch`, {
intent_ids: intentIds,
action,
}),
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) =>
+212
View File
@@ -0,0 +1,212 @@
import type { CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
export const MAX_COMPANY_SCOPE_RULES = 256;
export const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
export type CompanyScopeTextIssue = {
line: number;
rule?: CompanyScopeRule;
error?: string;
};
export type ParsedCompanyScopeText = {
rules: CompanyScopeRule[];
errors: Array<{ line: number; error: string }>;
};
export const COMPANY_SCOPE_KINDS = new Set<CompanyScopeKind>(["domain", "ip", "cidr", "icp", "keyword"]);
export function isCompanyScopeKind(value: unknown): value is CompanyScopeKind {
return typeof value === "string" && COMPANY_SCOPE_KINDS.has(value as CompanyScopeKind);
}
function isIPv4(value: string): boolean {
const parts = value.split(".");
return (
parts.length === 4 && parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) <= 255)
);
}
function isIPv6(value: string): boolean {
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
const halves = value.split("::");
if (halves.length > 2) return false;
const countSegments = (half: string): number | null => {
if (!half) return 0;
const segments = half.split(":");
let count = 0;
for (const [index, segment] of segments.entries()) {
if (segment.includes(".")) {
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
count += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
count++;
}
}
return count;
};
const left = countSegments(halves[0]);
const right = countSegments(halves[1] ?? "");
if (left === null || right === null) return false;
return halves.length === 2 ? left + right < 8 : left === 8;
}
function ipVersion(value: string): 4 | 6 | null {
if (isIPv4(value)) return 4;
if (isIPv6(value)) return 6;
return null;
}
function looksLikeIPAddress(value: string): boolean {
const trimmed = value.trim();
if (trimmed.includes("://") || /\s/.test(trimmed)) return false;
if ((trimmed.match(/:/g) ?? []).length >= 2) {
const parts = trimmed.split(":");
let validSegments = 0;
for (const part of parts) {
if (!part) {
if (validSegments > 0 || trimmed.startsWith("::")) return true;
continue;
}
if (part.length > 4 || !/^[0-9a-f]+$/i.test(part)) return false;
validSegments++;
if (validSegments >= 2) return true;
}
return false;
}
return trimmed.includes(".") && /^[0-9.]+$/.test(trimmed);
}
function domainHostname(value: string): string | null {
try {
let candidate = value;
if (value.startsWith("//")) candidate = `http:${value}`;
else if (!value.includes("://")) candidate = `http://${value}`;
const url = new URL(candidate);
return (
url.hostname
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
.toLowerCase() || null
);
} catch {
return null;
}
}
export function companyScopeRuleError(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (!value) return "请填写范围值";
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符`;
}
if (rule.kind === "domain") {
if (/\s/.test(value)) return "请输入不含空格的有效域名或 URL";
const hostname = domainHostname(value);
if (!hostname || ipVersion(hostname) !== null) return "请输入有效域名或 URL";
const labels = hostname.split(".");
if (
hostname.length > 253 ||
labels.length < 2 ||
labels.some(
(label) =>
!label || label.length > 63 || label.startsWith("-") || label.endsWith("-") || !/^[a-z0-9-]+$/i.test(label),
)
) {
return "请输入有效域名或 URL";
}
}
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
if (rule.kind === "cidr") {
const separator = value.lastIndexOf("/");
if (separator <= 0) return "请输入 CIDR 网段";
const address = value.slice(0, separator);
const prefixText = value.slice(separator + 1);
const version = ipVersion(address);
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
const prefix = Number(prefixText);
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
}
return "";
}
export function classifyCompanyScopeLine(
raw: string,
line: number,
preservedRule?: CompanyScopeRule,
): CompanyScopeTextIssue {
const value = raw.trim();
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return { line, error: `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符` };
}
if (preservedRule) {
const rule = { kind: preservedRule.kind, value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
const separator = value.lastIndexOf("/");
if (
separator > 0 &&
(ipVersion(value.slice(0, separator)) !== null || looksLikeIPAddress(value.slice(0, separator)))
) {
const rule: CompanyScopeRule = { kind: "cidr", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (ipVersion(value) !== null) return { line, rule: { kind: "ip", value } };
if (looksLikeIPAddress(value)) return { line, error: "请输入有效 IP" };
const looksLikeDomain = value.includes("://") || (!/\s/.test(value) && value.includes("."));
if (looksLikeDomain) {
const hostname = domainHostname(value);
if (hostname && ipVersion(hostname) !== null) return { line, rule: { kind: "ip", value: hostname } };
const rule: CompanyScopeRule = { kind: "domain", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (/icp|备案/i.test(value)) return { line, rule: { kind: "icp", value } };
return { line, rule: { kind: "keyword", value } };
}
export function parseCompanyScopeText(
value: string,
options: { preservedRules?: CompanyScopeRule[]; maxRules?: number } = {},
): ParsedCompanyScopeText {
const nonEmpty = value
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1 }))
.filter(({ raw }) => raw.trim());
const preserved = new Map<string, CompanyScopeRule[]>();
for (const rule of options.preservedRules ?? []) {
const key = rule.value.trim();
preserved.set(key, [...(preserved.get(key) ?? []), rule]);
}
const issues = nonEmpty.map(({ raw, line }) => {
const key = raw.trim();
const queue = preserved.get(key);
const preservedRule = queue?.shift();
return classifyCompanyScopeLine(raw, line, preservedRule);
});
const maxRules = options.maxRules ?? MAX_COMPANY_SCOPE_RULES;
if (nonEmpty.length > maxRules) {
issues.push({
line: nonEmpty[Math.max(0, maxRules)]?.line ?? 1,
error: `最多允许 ${maxRules} 条范围,当前 ${nonEmpty.length} 条`,
});
}
return {
rules: issues.flatMap((item) => (item.rule ? [item.rule] : [])),
errors: issues.flatMap((item) => (item.error ? [{ line: item.line, error: item.error }] : [])),
};
}
export function normalizeCompanyScopeValue(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (rule.kind === "domain") return domainHostname(value) ?? value.toLowerCase();
if (rule.kind === "icp") return value.toLowerCase().replace(/\s/gu, "");
if (rule.kind === "keyword") return value.toLowerCase().split(/\s+/u).filter(Boolean).join(" ");
return value.toLowerCase();
}
+69 -27
View File
@@ -2,6 +2,13 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import {
classifyCompanyScopeLine,
companyScopeRuleError,
isCompanyScopeKind,
MAX_COMPANY_SCOPE_RULES,
normalizeCompanyScopeValue,
} from "../company-scope";
import type {
Asset,
BatchControlItem,
@@ -36,24 +43,53 @@ function mockAssetCounts(): Record<string, number> {
}, {});
}
function mockScopeRows(companyID: number, input: unknown): ScopeRow[] {
if (!Array.isArray(input)) return [];
function mockScopeRows(
companyID: number,
input: unknown,
existing: ScopeRow[] = [],
): { rows: ScopeRow[]; invalid: number; skipped: number } {
if (!Array.isArray(input)) return { rows: [], invalid: 0, skipped: 0 };
if (input.length > MAX_COMPANY_SCOPE_RULES) throw new Error(`企业范围最多支持 ${MAX_COMPANY_SCOPE_RULES} 条`);
let nextID =
mockCompanies.flatMap((company) => company.scope ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
const rows: ScopeRow[] = [];
for (const candidate of input) {
if (!candidate || typeof candidate !== "object") continue;
const rule = candidate as Partial<CompanyScopeRule>;
const kind = rule.kind;
const value = String(rule.value ?? "").trim();
if (!kind || !value) continue;
const row: ScopeRow = { id: nextID++, company_id: companyID, kind, raw: value };
if (kind === "domain") row.domain = value.toLowerCase();
else if (kind === "ip" || kind === "cidr") row.net = value;
else row.value = value;
let invalid = 0;
let skipped = 0;
const keys = new Set(
existing.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.raw.trim().toLowerCase()}`),
);
for (const [index, candidate] of input.entries()) {
let rule: CompanyScopeRule | undefined;
if (typeof candidate === "string") {
rule = classifyCompanyScopeLine(candidate, index + 1).rule;
} else if (candidate && typeof candidate === "object") {
const item = candidate as { kind?: unknown; value?: unknown };
const value = String(item.value ?? "").trim();
if (item.kind === undefined || item.kind === "") rule = classifyCompanyScopeLine(value, index + 1).rule;
else if (isCompanyScopeKind(item.kind)) rule = { kind: item.kind, value };
}
if (!rule || companyScopeRuleError(rule)) {
invalid++;
continue;
}
const normalized = normalizeCompanyScopeValue(rule);
const row: ScopeRow = { id: nextID++, company_id: companyID, kind: rule.kind, raw: rule.value.trim() };
if (rule.kind === "domain") row.domain = normalized;
else if (rule.kind === "ip") row.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
else if (rule.kind === "cidr") row.net = normalized;
else row.value = normalized;
const key = `${row.kind}|${row.domain ?? row.net ?? row.value}`;
if (keys.has(key)) {
skipped++;
continue;
}
keys.add(key);
rows.push(row);
}
return rows;
if (existing.length + rows.length > MAX_COMPANY_SCOPE_RULES) {
throw new Error(`企业范围规则过多:每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条`);
}
return { rows, invalid, skipped };
}
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
@@ -109,15 +145,17 @@ function bodyIDs(value: unknown): string[] {
return ids;
}
function controlMockIntent(id: string, action: "pause" | "resume"): BatchControlItem {
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
const intent = mockIntents.find((item) => item.id === id);
if (!intent) return { id, ok: false, error: "意图不存在" };
if (!intent) return { ok: false, error: "意图不存在" };
const requiredState = action === "pause" ? "running" : "paused";
if (intent.inherited || intent.state !== requiredState) {
return { id, ok: false, state: intent.state, error: "Worker 状态已变化" };
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
}
intent.state = action === "pause" ? "paused" : "open";
return { id, ok: true, state: intent.state };
return { ok: true, state: intent.state };
}
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
@@ -357,10 +395,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const action = b.action === "resume" ? "resume" : "pause";
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
}
if (seg[0] === "tasks" && seg[2] === "intents" && seg[3] === "control" && seg[4] === "batch" && m === "POST") {
const action = b.action === "resume" ? "resume" : "pause";
return { items: bodyIDs(b.intent_ids).map((id) => controlMockIntent(id, action)) };
}
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "control" && m === "POST") {
const id = seg[3];
if (b.action === "cancel") {
@@ -515,17 +549,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
throw new Error("企业已存在");
}
const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1;
const scope = mockScopeRows(id, b.scope);
const company: Company = { id, name, asset_count: 0, scope };
const scopeResult = mockScopeRows(id, b.scope);
const company: Company = { id, name, asset_count: 0, scope: scopeResult.rows };
mockCompanies.push(company);
return { id, created: true, scope_added: scope.length, scope_invalid: 0 };
return {
id,
created: true,
scope_added: scopeResult.rows.length,
scope_skipped: scopeResult.skipped,
scope_invalid: scopeResult.invalid,
};
}
if (seg[0] === "companies" && seg[2] === "scope" && m === "POST") {
const company = mockCompanies.find((item) => item.id === Number(seg[1]));
if (!company) throw new Error("企业不存在");
const rows = mockScopeRows(company.id, b.scope);
company.scope = b.reset === true ? rows : [...(company.scope ?? []), ...rows];
return { added: rows.length, skipped: 0, invalid: 0 };
const reset = b.reset === true;
const scopeResult = mockScopeRows(company.id, b.scope, reset ? [] : (company.scope ?? []));
if (reset && scopeResult.invalid > 0) throw new Error("企业范围包含无效规则,未覆盖原有范围");
company.scope = reset ? scopeResult.rows : [...(company.scope ?? []), ...scopeResult.rows];
return { added: scopeResult.rows.length, skipped: scopeResult.skipped, invalid: scopeResult.invalid };
}
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") {
const id = Number(seg[1]);
-1
View File
@@ -555,7 +555,6 @@ export interface BatchControlItem {
id: string;
ok: boolean;
status?: string;
state?: string;
queued?: boolean;
error?: string;
}