feat(tasks): add templates findings workflows and batch controls
This commit is contained in:
1 parent
b7e0b93023
commit
d3183dbbd2
65 files changed
+11603
-2198
No files matched your search
@@ -1,20 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Bot, ChevronDownIcon, Loader2Icon, PaperclipIcon, PlusIcon, SendIcon, Square, Trash2Icon, XIcon, ZapIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Bot,
|
||||
ChevronDownIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PaperclipIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
PlusIcon,
|
||||
SendIcon,
|
||||
Square,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,15 +31,21 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -46,8 +59,8 @@ function fmtBytes(n: number): string {
|
||||
|
||||
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
@@ -66,6 +79,10 @@ function fmtDuration(ms: number): string {
|
||||
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
|
||||
const HISTORY_PAGE = 200;
|
||||
|
||||
function conversationIsPinned(conversation: Conversation): boolean {
|
||||
return conversation.pinned ?? Boolean(conversation.pinned_at);
|
||||
}
|
||||
|
||||
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
||||
// console — shown while a turn is streaming.
|
||||
function LiveBadge() {
|
||||
@@ -113,7 +130,7 @@ function Composer({
|
||||
const atts = attachments ?? [];
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
// Enter 发送;Shift+Enter 换行。isComposing 时是中文等输入法在选字,不拦截。
|
||||
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
|
||||
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
@@ -144,8 +161,8 @@ function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
{leftSlot}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
|
||||
{onPickFiles && (
|
||||
<>
|
||||
<input
|
||||
@@ -170,7 +187,7 @@ function Composer({
|
||||
</>
|
||||
)}
|
||||
<Textarea
|
||||
className="max-h-40 min-h-10 flex-1 resize-none"
|
||||
className="max-h-40 min-h-10 min-w-0 flex-1 resize-none"
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
@@ -234,7 +251,10 @@ function LLMProfileRow({
|
||||
{/* default option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
|
||||
selected == null && "bg-accent",
|
||||
@@ -242,21 +262,28 @@ function LLMProfileRow({
|
||||
>
|
||||
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
|
||||
{activeDefault && (
|
||||
<span className="text-muted-foreground text-[11px]">{activeDefault.format} · {activeDefault.model}</span>
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
{activeDefault.format} · {activeDefault.model}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{profiles.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(Number(p.id)); setOpen(false); }}
|
||||
onClick={() => {
|
||||
onChange(Number(p.id));
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
|
||||
selected === Number(p.id) && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{p.name}</span>
|
||||
<span className="text-muted-foreground text-[11px]">{p.format} · {p.model}</span>
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
{p.format} · {p.model}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
@@ -326,7 +353,7 @@ function DraftChat({
|
||||
|
||||
const agentPicker = (
|
||||
<Select value={agentKey} onValueChange={setAgentKey}>
|
||||
<SelectTrigger size="sm" className="w-40 shrink-0">
|
||||
<SelectTrigger size="sm" className="w-full sm:w-40">
|
||||
<SelectValue placeholder="选择 Agent…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -336,7 +363,9 @@ function DraftChat({
|
||||
<Bot className="size-3.5" />
|
||||
{a.name}
|
||||
{!a.builtin && (
|
||||
<Badge variant="outline" className="px-1 py-0 text-[9px]">自定义</Badge>
|
||||
<Badge variant="outline" className="px-1 py-0 text-[9px]">
|
||||
自定义
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -353,9 +382,7 @@ function DraftChat({
|
||||
<Bot className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
|
||||
{agent?.description && (
|
||||
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
|
||||
)}
|
||||
{agent?.description && <p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
@@ -424,10 +451,7 @@ function ChatView({
|
||||
}
|
||||
|
||||
// conversation-scoped detail fetcher for the reused Transcript renderer.
|
||||
const fetchDetail = React.useCallback(
|
||||
(seq: number) => api.conversationMsgDetail(conv.id, seq),
|
||||
[conv.id],
|
||||
);
|
||||
const fetchDetail = React.useCallback((seq: number) => api.conversationMsgDetail(conv.id, seq), [conv.id]);
|
||||
|
||||
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
|
||||
const latestTodoSeq = React.useMemo(() => {
|
||||
@@ -462,7 +486,9 @@ function ChatView({
|
||||
setHasMore(r.hasMore);
|
||||
setRunning(r.running);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
/* The empty state remains usable when history loading fails. */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
@@ -497,9 +523,7 @@ function ChatView({
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const viewport = React.useCallback(
|
||||
() =>
|
||||
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
|
||||
null,
|
||||
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
|
||||
[],
|
||||
);
|
||||
// Scroll-up loads one older page and prepends it, preserving the visual position
|
||||
@@ -543,6 +567,7 @@ function ChatView({
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [viewport, loadEarlier]);
|
||||
// open/switch a conversation → jump to the latest (bottom)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: changing conversations intentionally retriggers the scroll reset.
|
||||
React.useLayoutEffect(() => {
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
@@ -551,6 +576,7 @@ function ChatView({
|
||||
}
|
||||
}, [conv.id, viewport]);
|
||||
// new activity → stick to bottom only if the user is already pinned there
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: message and running changes intentionally retrigger bottom anchoring.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
@@ -560,8 +586,12 @@ function ChatView({
|
||||
// Per-conversation token total, live — same accounting as the main-agent
|
||||
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
|
||||
const tokenTotal = React.useMemo(() => {
|
||||
let i = 0, o = 0, cr = 0;
|
||||
let li = 0, lo = 0, lcr = 0;
|
||||
let i = 0,
|
||||
o = 0,
|
||||
cr = 0;
|
||||
let li = 0,
|
||||
lo = 0,
|
||||
lcr = 0;
|
||||
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
|
||||
for (const a of messages) {
|
||||
if (a.kind === "result") {
|
||||
@@ -576,7 +606,9 @@ function ChatView({
|
||||
lcr = a.cache_read_tokens ?? 0;
|
||||
}
|
||||
}
|
||||
const I = i + li, O = o + lo, CR = cr + lcr;
|
||||
const I = i + li,
|
||||
O = o + lo,
|
||||
CR = cr + lcr;
|
||||
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
|
||||
}, [messages]);
|
||||
|
||||
@@ -636,10 +668,10 @@ function ChatView({
|
||||
return (
|
||||
<>
|
||||
{/* header: which agent + live + token meta */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-4 py-2.5">
|
||||
<Bot className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="shrink-0 text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono text-xs">{conv.agent_key}</span>
|
||||
<span className="min-w-0 max-w-48 truncate text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="text-muted-foreground hidden shrink-0 font-mono text-xs sm:inline">{conv.agent_key}</span>
|
||||
{agent && !agent.builtin && (
|
||||
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||||
自定义
|
||||
@@ -649,14 +681,14 @@ function ChatView({
|
||||
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
|
||||
)}
|
||||
{running && <LiveBadge />}
|
||||
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-3 text-xs">
|
||||
<div className="text-muted-foreground ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs max-sm:w-full max-sm:flex-wrap">
|
||||
{tokenTotal.turns > 0 && (
|
||||
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
|
||||
{tokenTotal.turns} 轮
|
||||
</span>
|
||||
)}
|
||||
{tokenTotal.any && (
|
||||
<span title="input / cache(read) / output tokens" className="tabular-nums">
|
||||
<span title="input / cache(read) / output tokens" className="min-w-0 truncate tabular-nums">
|
||||
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
|
||||
</span>
|
||||
)}
|
||||
@@ -664,10 +696,7 @@ function ChatView({
|
||||
</div>
|
||||
|
||||
{/* messages */}
|
||||
<ScrollArea
|
||||
type="auto"
|
||||
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
|
||||
>
|
||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
|
||||
{messages.length === 0 && !running ? (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
@@ -676,9 +705,7 @@ function ChatView({
|
||||
) : (
|
||||
<>
|
||||
{hasMore && (
|
||||
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">
|
||||
向上滚动加载更早的消息…
|
||||
</div>
|
||||
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">向上滚动加载更早的消息…</div>
|
||||
)}
|
||||
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
|
||||
</>
|
||||
@@ -711,8 +738,8 @@ function ChatView({
|
||||
);
|
||||
}
|
||||
|
||||
// ConversationItem is one row in the left rail — status-ish icon, title + agent
|
||||
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem.
|
||||
// ConversationItem is one row in the left rail: title, agent subtitle, inline
|
||||
// rename, pin marker, and a compact action menu.
|
||||
function ConversationItem({
|
||||
conv,
|
||||
agent,
|
||||
@@ -723,6 +750,8 @@ function ConversationItem({
|
||||
onStartRename,
|
||||
onRenameText,
|
||||
onCommitRename,
|
||||
onCancelRename,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
}: {
|
||||
conv: Conversation;
|
||||
@@ -734,8 +763,22 @@ function ConversationItem({
|
||||
onStartRename: () => void;
|
||||
onRenameText: (v: string) => void;
|
||||
onCommitRename: () => void;
|
||||
onCancelRename: () => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cancelRenameRef = React.useRef(false);
|
||||
const pinned = conversationIsPinned(conv);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!renaming) return;
|
||||
cancelRenameRef.current = false;
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}, [renaming]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -745,13 +788,19 @@ function ConversationItem({
|
||||
>
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
ref={renameInputRef}
|
||||
value={renameText}
|
||||
onChange={(e) => onRenameText(e.target.value)}
|
||||
onBlur={onCommitRename}
|
||||
onBlur={() => {
|
||||
if (!cancelRenameRef.current) onCommitRename();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommitRename();
|
||||
if (e.key === "Escape") onCommitRename();
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelRenameRef.current = true;
|
||||
onCancelRename();
|
||||
}
|
||||
}}
|
||||
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
|
||||
/>
|
||||
@@ -763,26 +812,58 @@ function ConversationItem({
|
||||
title="双击重命名"
|
||||
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
|
||||
>
|
||||
<div className="truncate text-sm">{conv.title || "新对话"}</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{pinned && <PinIcon className="text-primary size-3 shrink-0" aria-label="已置顶" />}
|
||||
<div className="truncate text-sm">{conv.title || "新对话"}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
|
||||
<Bot className="size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">{new Date(conv.created_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="shrink-0">
|
||||
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="shrink-0 opacity-60">#{conv.id}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label={`管理对话「${conv.title || "新对话"}」`}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onStartRename}>
|
||||
<PencilIcon />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onTogglePinned}>
|
||||
{pinned ? <PinOffIcon /> : <PinIcon />}
|
||||
{pinned ? "取消置顶" : "置顶"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
|
||||
@@ -813,11 +894,24 @@ export default function ChatPage() {
|
||||
>({});
|
||||
|
||||
const reloadConvs = React.useCallback(() => {
|
||||
api.conversations().then(setConvs).catch(() => setConvs([]));
|
||||
api
|
||||
.conversations()
|
||||
.then(setConvs)
|
||||
.catch(() => setConvs([]));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
api.agents().then(setAgents).catch(() => {});
|
||||
api.llmProfiles().then(setProfiles).catch(() => {});
|
||||
api
|
||||
.agents()
|
||||
.then(setAgents)
|
||||
.catch(() => {
|
||||
/* Agent metadata is optional for rendering an existing conversation. */
|
||||
});
|
||||
api
|
||||
.llmProfiles()
|
||||
.then(setProfiles)
|
||||
.catch(() => {
|
||||
/* The conversation remains usable without profile labels. */
|
||||
});
|
||||
reloadConvs();
|
||||
}, [reloadConvs]);
|
||||
|
||||
@@ -856,6 +950,16 @@ export default function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePinned(conversation: Conversation) {
|
||||
const pinned = conversationIsPinned(conversation);
|
||||
try {
|
||||
await api.pinConversation(conversation.id, !pinned);
|
||||
reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(c: Conversation) {
|
||||
setRenamingId(c.id);
|
||||
setRenameText(c.title || "");
|
||||
@@ -876,9 +980,9 @@ export default function ChatPage() {
|
||||
return (
|
||||
<div
|
||||
data-content-padding="false"
|
||||
className="flex h-[calc(100svh-3rem)] flex-col overflow-hidden p-4 md:h-[calc(100svh-4rem)] md:p-6"
|
||||
className="flex h-[calc(100svh-3rem)] min-w-0 flex-col overflow-hidden p-3 sm:p-4 md:h-[calc(100svh-4rem)] md:p-6"
|
||||
>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] grid-rows-[minmax(0,1fr)] gap-4">
|
||||
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
|
||||
{/* left: conversation list */}
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
<div className="border-b p-2">
|
||||
@@ -888,9 +992,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 p-2">
|
||||
{convs.length === 0 && (
|
||||
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
|
||||
)}
|
||||
{convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>}
|
||||
{convs.map((c) => (
|
||||
<ConversationItem
|
||||
key={c.id}
|
||||
@@ -903,6 +1005,8 @@ export default function ChatPage() {
|
||||
onStartRename={() => startRename(c)}
|
||||
onRenameText={setRenameText}
|
||||
onCommitRename={commitRename}
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onTogglePinned={() => void togglePinned(c)}
|
||||
onDelete={() => del(c.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -931,7 +1035,12 @@ export default function ChatPage() {
|
||||
// it on this render (switching to ChatView right away, before the
|
||||
// async reloadConvs lands); reloadConvs then reconciles titles etc.
|
||||
if (pending) setPendingByConv((p) => ({ ...p, [c.id]: pending }));
|
||||
setConvs((prev) => (prev.some((x) => x.id === c.id) ? prev : [c, ...prev]));
|
||||
setConvs((prev) => {
|
||||
if (prev.some((item) => item.id === c.id)) return prev;
|
||||
const firstUnpinned = prev.findIndex((item) => !conversationIsPinned(item));
|
||||
const insertAt = firstUnpinned < 0 ? prev.length : firstUnpinned;
|
||||
return [...prev.slice(0, insertAt), c, ...prev.slice(insertAt)];
|
||||
});
|
||||
setSelectedId(c.id);
|
||||
reloadConvs();
|
||||
}}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface TablePaginationProps {
|
||||
page: number; // 1-based
|
||||
@@ -49,19 +39,20 @@ export function TablePagination({
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
}: TablePaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const to = Math.min(page * pageSize, total);
|
||||
const safePage = Math.min(Math.max(1, page), totalPages);
|
||||
const from = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||
const to = Math.min(safePage * pageSize, total);
|
||||
const onPageChangeRef = useRef(onPageChange);
|
||||
onPageChangeRef.current = onPageChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== safePage) onPageChangeRef.current(safePage);
|
||||
}, [page, safePage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
onPageSizeChange(Number(v));
|
||||
onPageChange(1);
|
||||
}}
|
||||
>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -90,14 +81,14 @@ export function TablePagination({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={safePage === 1}
|
||||
onClick={() => onPageChange(safePage - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
{pageWindows(page, totalPages).map((p, i) =>
|
||||
{pageWindows(safePage, totalPages).map((p, i) =>
|
||||
p === "..." ? (
|
||||
<PaginationItem key={`el-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
@@ -105,10 +96,10 @@ export function TablePagination({
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<Button
|
||||
variant={p === page ? "outline" : "ghost"}
|
||||
variant={p === safePage ? "outline" : "ghost"}
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(p)}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
aria-current={p === safePage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
@@ -119,8 +110,8 @@ export function TablePagination({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={safePage === totalPages}
|
||||
onClick={() => onPageChange(safePage + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
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 { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TaskTemplate } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TemplateDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
}
|
||||
|
||||
interface TaskTemplateManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templates: TaskTemplate[];
|
||||
seed: Pick<TemplateDraft, "description" | "goal"> | null;
|
||||
onCreated: (template: TaskTemplate) => void;
|
||||
onUpdated: (template: TaskTemplate) => void;
|
||||
onDeleted: (id: number) => void;
|
||||
}
|
||||
|
||||
const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" });
|
||||
|
||||
function templateDraft(template: TaskTemplate): TemplateDraft {
|
||||
return {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
goal: template.goal,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskTemplateManager({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates,
|
||||
seed,
|
||||
onCreated,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: TaskTemplateManagerProps) {
|
||||
const [selectedID, setSelectedID] = React.useState<number | null>(null);
|
||||
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const wasOpen = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
if (seed) {
|
||||
setSelectedID(null);
|
||||
setDraft({ name: "", description: seed.description, goal: seed.goal });
|
||||
} else if (templates[0]) {
|
||||
setSelectedID(templates[0].id);
|
||||
setDraft(templateDraft(templates[0]));
|
||||
} else {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
}
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open, seed, templates]);
|
||||
|
||||
const selectTemplate = (template: TaskTemplate) => {
|
||||
setSelectedID(template.id);
|
||||
setDraft(templateDraft(template));
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
};
|
||||
|
||||
const updateDraft = (field: keyof TemplateDraft, value: string) => {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const input = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
goal: draft.goal.trim(),
|
||||
};
|
||||
if (!input.name || !input.description || !input.goal) {
|
||||
toast.error("请填写模板名称、描述和目标");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (selectedID == null) {
|
||||
const created = await api.createTaskTemplate(input);
|
||||
onCreated(created);
|
||||
setSelectedID(created.id);
|
||||
setDraft(templateDraft(created));
|
||||
toast.success("模板已创建");
|
||||
} else {
|
||||
const updated = await api.updateTaskTemplate(selectedID, input);
|
||||
onUpdated(updated);
|
||||
setDraft(templateDraft(updated));
|
||||
toast.success("模板已更新");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`保存失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (selectedID == null) return;
|
||||
const deletedID = selectedID;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteTaskTemplate(deletedID);
|
||||
onDeleted(deletedID);
|
||||
const next = templates.find((template) => template.id !== deletedID);
|
||||
if (next) {
|
||||
setSelectedID(next.id);
|
||||
setDraft(templateDraft(next));
|
||||
} else {
|
||||
startNew();
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
toast.success("模板已删除");
|
||||
} catch (error) {
|
||||
toast.error(`删除失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
let saveLabel = saving ? "保存中" : "保存修改";
|
||||
if (!saving && selectedID == null) saveLabel = "创建模板";
|
||||
|
||||
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">
|
||||
<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]">
|
||||
<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>
|
||||
)}
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
|
||||
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => selectTemplate(template)}
|
||||
>
|
||||
<span className="block truncate font-medium text-sm">{template.name}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[58svh]">
|
||||
<FieldGroup className="p-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
|
||||
<Input
|
||||
id="task-template-name"
|
||||
value={draft.name}
|
||||
maxLength={120}
|
||||
placeholder="例如:外部 Web 渗透"
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-description">描述</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-description"
|
||||
className="min-h-28"
|
||||
value={draft.description}
|
||||
placeholder="测试对象与背景"
|
||||
onChange={(event) => updateDraft("description", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-goal">目标</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-goal"
|
||||
className="min-h-28"
|
||||
value={draft.goal}
|
||||
placeholder="任务需要达成的目标"
|
||||
onChange={(event) => updateDraft("goal", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
{selectedID != null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除模板
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除模板「{draft.name || "未命名模板"}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>已由该模板创建的任务不会受到影响。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void remove();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? "删除中" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskTemplateControlsProps {
|
||||
description: string;
|
||||
goal: string;
|
||||
selectedTemplateID: number | null;
|
||||
onSelectedTemplateIDChange: (id: number | null) => void;
|
||||
onApply: (template: TaskTemplate) => void;
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function TaskTemplateControls({
|
||||
description,
|
||||
goal,
|
||||
selectedTemplateID,
|
||||
onSelectedTemplateIDChange,
|
||||
onApply,
|
||||
portalContainer,
|
||||
}: TaskTemplateControlsProps) {
|
||||
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
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);
|
||||
|
||||
const loadTemplates = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTemplates(await api.taskTemplates());
|
||||
} catch (error) {
|
||||
setTemplates([]);
|
||||
toast.error(`加载模板失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
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 applyTemplate = (template: TaskTemplate) => {
|
||||
onApply(template);
|
||||
onSelectedTemplateIDChange(template.id);
|
||||
setPendingTemplate(null);
|
||||
};
|
||||
|
||||
const chooseTemplate = (value: string | null) => {
|
||||
if (!value) {
|
||||
onSelectedTemplateIDChange(null);
|
||||
return;
|
||||
}
|
||||
const template = templatesByID.get(value);
|
||||
if (!template) return;
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
if (hasContent && changesContent) {
|
||||
setPendingTemplate(template);
|
||||
return;
|
||||
}
|
||||
applyTemplate(template);
|
||||
};
|
||||
|
||||
const openManager = (seed: Pick<TemplateDraft, "description" | "goal"> | null) => {
|
||||
setManagerSeed(seed);
|
||||
setManagerOpen(true);
|
||||
};
|
||||
|
||||
const upsertTemplate = (template: TaskTemplate) => {
|
||||
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
|
||||
};
|
||||
|
||||
const deleteTemplate = (id: number) => {
|
||||
setTemplates((current) => current.filter((template) => template.id !== id));
|
||||
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
|
||||
};
|
||||
|
||||
let pickerPlaceholder = loading ? "正在加载模板" : "暂无任务模板";
|
||||
if (!loading && templates.length > 0) pickerPlaceholder = "搜索并选择任务模板";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel htmlFor="task-template-picker">任务模板</FieldLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
|
||||
<Settings2Icon data-icon="inline-start" />
|
||||
管理模板
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!description.trim() || !goal.trim()}
|
||||
onClick={() => openManager({ description, goal })}
|
||||
>
|
||||
<SaveIcon data-icon="inline-start" />
|
||||
另存为模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={templateIDs}
|
||||
itemToStringValue={(id) => templatesByID.get(id)?.name ?? id}
|
||||
value={selectedTemplateID == null ? null : String(selectedTemplateID)}
|
||||
onValueChange={chooseTemplate}
|
||||
>
|
||||
<ComboboxInput
|
||||
id="task-template-picker"
|
||||
className="w-full"
|
||||
placeholder={pickerPlaceholder}
|
||||
disabled={loading || templates.length === 0}
|
||||
showClear
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog open={pendingTemplate != null} onOpenChange={(open) => !open && setPendingTemplate(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>当前已填写的描述和目标将被模板内容覆盖。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
|
||||
覆盖并使用
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<TaskTemplateManager
|
||||
open={managerOpen}
|
||||
onOpenChange={setManagerOpen}
|
||||
templates={templates}
|
||||
seed={managerSeed}
|
||||
onCreated={upsertTemplate}
|
||||
onUpdated={upsertTemplate}
|
||||
onDeleted={deleteTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
"min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -55,7 +55,7 @@ function AlertDescription({
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
"min-w-0 text-sm text-balance text-muted-foreground [overflow-wrap:anywhere] md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+51
-26
@@ -13,9 +13,11 @@ import type {
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
CommandRecord,
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
ConvTokenSummary,
|
||||
CoverageAssetRefs,
|
||||
@@ -25,6 +27,8 @@ import type {
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
@@ -39,21 +43,24 @@ import type {
|
||||
LLMTask,
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
MissingSkill,
|
||||
ModelTokenStat,
|
||||
PromptVar,
|
||||
PromptVersion,
|
||||
SessionTokenUsage,
|
||||
Settings,
|
||||
Severity,
|
||||
SkillCall,
|
||||
SkillItem,
|
||||
MissingSkill,
|
||||
SSProject,
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetView,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
TaskTemplate,
|
||||
TokenTotal,
|
||||
TokenUsage,
|
||||
Tool,
|
||||
@@ -179,6 +186,7 @@ export const api = {
|
||||
goal: string;
|
||||
llmProfileIds?: number[];
|
||||
sourceTaskIds?: string[];
|
||||
companyIds?: number[];
|
||||
timeoutSeconds?: number;
|
||||
seedFirstIntent?: boolean;
|
||||
planHeartbeatSeconds?: number;
|
||||
@@ -188,10 +196,17 @@ export const api = {
|
||||
goal: input.goal,
|
||||
llm_profile_ids: input.llmProfileIds ?? [],
|
||||
source_task_ids: input.sourceTaskIds ?? [],
|
||||
company_ids: input.companyIds ?? [],
|
||||
timeout_seconds: input.timeoutSeconds ?? 0,
|
||||
seed_first_intent: input.seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
}),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||
post<TaskTemplate>("/task-templates", input),
|
||||
updateTaskTemplate: (id: number, input: Partial<Pick<TaskTemplate, "name" | "description" | "goal">>) =>
|
||||
patch<TaskTemplate>(`/task-templates/${id}`, input),
|
||||
deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`),
|
||||
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
|
||||
put<{
|
||||
id: string;
|
||||
@@ -206,13 +221,21 @@ export const api = {
|
||||
}),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") =>
|
||||
post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
post<{ id: string; paused: boolean; queued: boolean; status: string }>(`/tasks/${id}/control`, { action }),
|
||||
controlTasksBatch: (taskIds: string[], action: "pause" | "resume") =>
|
||||
post<{ items: BatchControlItem[] }>("/tasks/control/batch", { task_ids: taskIds, action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
post<{
|
||||
id: number;
|
||||
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) =>
|
||||
post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
@@ -308,38 +331,22 @@ export const api = {
|
||||
|
||||
// ---- companies (企业 + 资产范围;归属唯一来源) ----
|
||||
companies: () => get<Company[]>("/companies").then(arr),
|
||||
createCompany: (name: string, logo: string, scope: string) =>
|
||||
createCompany: (name: string, scope: CompanyScopeRule[]) =>
|
||||
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
|
||||
"/companies",
|
||||
{
|
||||
name,
|
||||
logo,
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
scope,
|
||||
},
|
||||
),
|
||||
addCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
addCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
scope,
|
||||
reason,
|
||||
}),
|
||||
updateCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
scope,
|
||||
reason,
|
||||
reset: true,
|
||||
}),
|
||||
@@ -361,6 +368,15 @@ export const api = {
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingGroups: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
if (q.task && q.task !== "all") p.set("task_id", q.task);
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
|
||||
},
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
@@ -415,10 +431,15 @@ export const api = {
|
||||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
||||
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
|
||||
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
|
||||
deepenFinding: (id: string, description: string) =>
|
||||
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
|
||||
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||
tokenStats: (task?: string) =>
|
||||
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
|
||||
get<{ workers: TokenUsage[]; sessions: SessionTokenUsage[]; total: TokenTotal }>(
|
||||
`/exploration/tokens${tq(task)}`,
|
||||
).then((r) => ({
|
||||
workers: arr(r.workers),
|
||||
sessions: arr(r.sessions),
|
||||
total: r.total,
|
||||
})),
|
||||
tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
@@ -505,6 +526,7 @@ export const api = {
|
||||
},
|
||||
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
|
||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
|
||||
if (MOCK)
|
||||
@@ -619,7 +641,10 @@ export const api = {
|
||||
conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)),
|
||||
createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) =>
|
||||
post<Conversation>("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? null }),
|
||||
renameConversation: (id: number, title: string) => patch<{ ok: boolean }>(`/conversations/${id}`, { title }),
|
||||
updateConversation: (id: number, input: { title?: string; pinned?: boolean }) =>
|
||||
patch<Conversation>(`/conversations/${id}`, input),
|
||||
renameConversation: (id: number, title: string) => patch<Conversation>(`/conversations/${id}`, { title }),
|
||||
pinConversation: (id: number, pinned: boolean) => patch<Conversation>(`/conversations/${id}`, { pinned }),
|
||||
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
||||
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
||||
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
|
||||
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
||||
InterceptPending, InterceptRule, LLMPoolStatus, LLMProfile, MCPServer, MCPTool, PromptVar,
|
||||
PromptVersion, Settings, SkillCall, SkillItem, MissingSkill, Stats, TaskNode, Task, TokenTotal,
|
||||
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||
PromptVersion, Settings, SessionTokenUsage, SkillCall, SkillItem, MissingSkill, Stats, TaskNode, Task, TokenTotal,
|
||||
TaskTemplate, TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||
} from "@/lib/types";
|
||||
|
||||
const T = (iso: string) => iso; // readability helper for timestamps
|
||||
@@ -93,6 +93,25 @@ export const tasks: Task[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const taskTemplates: TaskTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "外部 Web 渗透",
|
||||
description: "对目标互联网暴露面开展黑盒渗透测试,覆盖站点、接口和常见管理入口。",
|
||||
goal: "识别并验证可造成未授权访问、敏感数据泄露或服务器失陷的安全问题。",
|
||||
created_at: T("2026-07-20T08:00:00Z"),
|
||||
updated_at: T("2026-07-25T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "API 越权专项",
|
||||
description: "围绕目标 API 的身份认证、对象级授权和角色边界开展专项测试。",
|
||||
goal: "确认是否存在 IDOR、水平越权、垂直越权及批量数据访问风险。",
|
||||
created_at: T("2026-07-19T08:00:00Z"),
|
||||
updated_at: T("2026-07-24T08:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
export const getTask = (id: string) => tasks.find((t) => t.id === id);
|
||||
|
||||
const grandTotal: TokenTotal = tasks.reduce(
|
||||
@@ -516,6 +535,33 @@ export const tokenWorkers: TokenUsage[] = [
|
||||
{ worker: "work#13", input_tokens: 144000, output_tokens: 9200, cache_read_tokens: 96000, cache_write_tokens: 14000 },
|
||||
];
|
||||
|
||||
// Mirrors TokenStatsBySession: completed usage is keyed by the stable UI session
|
||||
// (main | plan | intent:<id>) instead of the reusable work#N executor name.
|
||||
export const tokenSessions: SessionTokenUsage[] = (() => {
|
||||
const totals = new Map<string, SessionTokenUsage>();
|
||||
for (const item of activity) {
|
||||
if (item.kind !== "result") continue;
|
||||
let session = "";
|
||||
if (item.worker === "mainagent") session = "main";
|
||||
else if (item.worker === "planner") session = "plan";
|
||||
else if (item.intent_id) session = `intent:${item.intent_id}`;
|
||||
if (!session) continue;
|
||||
const current = totals.get(session) ?? {
|
||||
session,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
current.input_tokens += item.input_tokens ?? 0;
|
||||
current.output_tokens += item.output_tokens ?? 0;
|
||||
current.cache_read_tokens += item.cache_read_tokens ?? 0;
|
||||
current.cache_write_tokens += item.cache_write_tokens ?? 0;
|
||||
totals.set(session, current);
|
||||
}
|
||||
return [...totals.values()];
|
||||
})();
|
||||
|
||||
export const tokenTotal: TokenTotal = grandTotal;
|
||||
|
||||
export const dailyTokens: DailyTokenBucket[] = Array.from({ length: 30 }, (_, i) => {
|
||||
@@ -755,7 +801,7 @@ export const interceptHistory: InterceptApprovalRow[] = [
|
||||
|
||||
// ── Conversations (chat) ─────────────────────────────────────────────────────
|
||||
export const conversations: Conversation[] = [
|
||||
{ id: 1, agent_key: "mainagent", title: "acme 后台切入点讨论", llm_profile_id: 1, created_at: T("2026-07-25T10:00:00Z"), updated_at: T("2026-07-26T03:40:00Z") },
|
||||
{ id: 1, agent_key: "mainagent", title: "acme 后台切入点讨论", llm_profile_id: 1, pinned: true, pinned_at: T("2026-07-26T04:00:00Z"), created_at: T("2026-07-25T10:00:00Z"), updated_at: T("2026-07-26T03:40:00Z") },
|
||||
{ id: 2, agent_key: "mainagent", title: "API 越权面梳理", llm_profile_id: 2, created_at: T("2026-07-24T16:00:00Z"), updated_at: T("2026-07-24T17:20:00Z") },
|
||||
{ id: 3, agent_key: "mainagent", title: "内网横向 · DMZ→域控攻击路径规划", llm_profile_id: 1, created_at: T("2026-07-25T15:45:00Z"), updated_at: T("2026-07-26T00:25:00Z") },
|
||||
];
|
||||
|
||||
+429
-22
@@ -2,7 +2,17 @@
|
||||
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
|
||||
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
|
||||
|
||||
import type { Task } from "../types";
|
||||
import type {
|
||||
Asset,
|
||||
BatchControlItem,
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
ScopeRow,
|
||||
Task,
|
||||
TaskLLMResolution,
|
||||
TaskTemplate,
|
||||
} from "../types";
|
||||
import * as D from "./data";
|
||||
|
||||
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -12,8 +22,148 @@ const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||
const mockConversations = structuredClone(D.conversations);
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
let mockActiveTask = D.ACTIVE_TASK;
|
||||
|
||||
function mockAssetCounts(): Record<string, number> {
|
||||
return mockAssets.reduce<Record<string, number>>((counts, asset) => {
|
||||
counts[asset.type] = (counts[asset.type] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mockScopeRows(companyID: number, input: unknown): ScopeRow[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
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;
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
|
||||
const profile = D.llmProfiles.find((item) => Number(item.id) === profileID);
|
||||
if (!profile) {
|
||||
return { name: "", format: "", model: "", source, available: false, reason: "LLM 配置不存在" };
|
||||
}
|
||||
return {
|
||||
profile_id: Number(profile.id),
|
||||
name: profile.name,
|
||||
format: profile.format,
|
||||
model: profile.model,
|
||||
source,
|
||||
available: Boolean(profile.api_key_hint),
|
||||
reason: profile.api_key_hint ? undefined : "LLM 配置未设置 API Key",
|
||||
};
|
||||
}
|
||||
|
||||
function mockRoleResolution(task: Task, agentKey: "mainagent" | "planner" | "worker"): TaskLLMResolution {
|
||||
if (task.llm_profile_ids?.length) {
|
||||
if (task.active_llm_profile_id === undefined) {
|
||||
return {
|
||||
name: "",
|
||||
format: "",
|
||||
model: "",
|
||||
source: "task_chain",
|
||||
available: false,
|
||||
reason: "任务 LLM 配置链额度已耗尽",
|
||||
};
|
||||
}
|
||||
return mockProfileResolution(task.active_llm_profile_id, "task_chain");
|
||||
}
|
||||
const agent = D.agents.find((item) => item.key === agentKey);
|
||||
if (agent?.llm_profile_id) {
|
||||
const bound = mockProfileResolution(agent.llm_profile_id, "agent_binding");
|
||||
if (bound.available) return bound;
|
||||
}
|
||||
const globalProfile = D.llmProfiles.find((item) => item.is_default);
|
||||
if (globalProfile) return mockProfileResolution(Number(globalProfile.id), "global_profile");
|
||||
return {
|
||||
name: "全局配置",
|
||||
format: D.llmConfig.provider,
|
||||
model: D.llmConfig.model,
|
||||
source: "environment",
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
function bodyIDs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids = [...new Set(value.map((item) => String(item)).filter(Boolean))];
|
||||
if (ids.length > 100) throw new Error("批量操作最多支持 100 个 ID");
|
||||
return ids;
|
||||
}
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
if (!intent) return { id, ok: false, error: "意图不存在" };
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
if (intent.inherited || intent.state !== requiredState) {
|
||||
return { id, ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
}
|
||||
intent.state = action === "pause" ? "paused" : "open";
|
||||
return { id, ok: true, state: intent.state };
|
||||
}
|
||||
|
||||
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
const task = mockTasks.find((item) => item.id === id);
|
||||
if (!task) return { id, ok: false, error: "任务不存在" };
|
||||
if (task.status === "done" || task.status === "failed" || task.status === "timeout") {
|
||||
return { id, ok: false, status: task.status, error: "终态任务不可控制" };
|
||||
}
|
||||
if (action === "pause") {
|
||||
if (task.paused || task.status === "paused") {
|
||||
return { id, ok: false, status: task.status, error: "任务已经暂停" };
|
||||
}
|
||||
task.paused = true;
|
||||
task.queued = false;
|
||||
task.status = "paused";
|
||||
task.engine_mode = "paused";
|
||||
} else {
|
||||
if (!task.paused && task.status !== "paused") {
|
||||
return { id, ok: false, status: task.status, error: "任务未暂停" };
|
||||
}
|
||||
task.paused = false;
|
||||
task.queued = false;
|
||||
task.status = "running";
|
||||
task.engine_mode = "exploring";
|
||||
}
|
||||
return { id, ok: true, status: task.status, queued: false };
|
||||
}
|
||||
|
||||
function sortMockConversations() {
|
||||
mockConversations.sort((a, b) => {
|
||||
const aPinned = a.pinned_at ? 1 : 0;
|
||||
const bPinned = b.pinned_at ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
const aTime = a.pinned_at ?? a.updated_at;
|
||||
const bTime = b.pinned_at ?? b.updated_at;
|
||||
return bTime.localeCompare(aTime) || b.id - a.id;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedTemplateName(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
if (typeof body !== "string") return {};
|
||||
try {
|
||||
@@ -50,6 +200,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const now = new Date();
|
||||
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
|
||||
const created: Task = {
|
||||
id,
|
||||
description: String(b.description ?? "新任务"),
|
||||
@@ -70,12 +221,64 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
active_llm_profile_id: profileIDs[0],
|
||||
llm_failover_state: profileIDs.length ? "ready" : "default",
|
||||
source_task_ids: sourceTaskIDs,
|
||||
company_ids: companyIDs,
|
||||
};
|
||||
for (const item of mockTasks) item.active = false;
|
||||
mockTasks.unshift(created);
|
||||
mockActiveTask = id;
|
||||
return created;
|
||||
}
|
||||
if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates };
|
||||
if (path === "/task-templates" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
const name = normalizedTemplateName(b.name);
|
||||
const description = String(b.description ?? "").trim();
|
||||
const goal = String(b.goal ?? "").trim();
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some((template) => normalizedTemplateName(template.name).toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
const nextID = mockTaskTemplates.reduce((max, template) => Math.max(max, template.id), 0) + 1;
|
||||
const created: TaskTemplate = {
|
||||
id: nextID,
|
||||
name,
|
||||
description,
|
||||
goal,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockTaskTemplates.unshift(created);
|
||||
return created;
|
||||
}
|
||||
if (seg[0] === "task-templates" && seg.length === 2 && m === "PATCH") {
|
||||
const template = mockTaskTemplates.find((item) => item.id === Number(seg[1]));
|
||||
if (!template) return {};
|
||||
const name = typeof b.name === "string" ? normalizedTemplateName(b.name) : template.name;
|
||||
const description = typeof b.description === "string" ? b.description.trim() : template.description;
|
||||
const goal = typeof b.goal === "string" ? b.goal.trim() : template.goal;
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some(
|
||||
(item) => item.id !== template.id && normalizedTemplateName(item.name).toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
template.name = name;
|
||||
template.description = description;
|
||||
template.goal = goal;
|
||||
template.updated_at = new Date().toISOString();
|
||||
mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id);
|
||||
return template;
|
||||
}
|
||||
if (seg[0] === "task-templates" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
const index = mockTaskTemplates.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
||||
return { deleted: id };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
||||
const id = seg[1];
|
||||
const index = mockTasks.findIndex((item) => item.id === id);
|
||||
@@ -88,6 +291,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
mockFindings.splice(i, 1);
|
||||
findingsDeleted++;
|
||||
}
|
||||
} else {
|
||||
// PostgreSQL uses ON DELETE SET NULL for retained findings. Keep the mock
|
||||
// grouped view consistent by moving them into the unassigned/deleted bucket.
|
||||
for (const finding of mockFindings) {
|
||||
if (finding.task_id !== id) continue;
|
||||
finding.task_id = undefined;
|
||||
finding.task_description = "";
|
||||
}
|
||||
}
|
||||
|
||||
let llmRecordsDeleted = 0;
|
||||
@@ -133,7 +344,56 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
reopened_intents: 0,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
|
||||
if (seg[0] === "tasks" && seg[2] === "llm" && seg[3] === "resolution" && m === "GET") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
return {
|
||||
mainagent: mockRoleResolution(task, "mainagent"),
|
||||
planner: mockRoleResolution(task, "planner"),
|
||||
worker: mockRoleResolution(task, "worker"),
|
||||
};
|
||||
}
|
||||
if (path === "/tasks/control/batch" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[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") {
|
||||
const index = mockIntents.findIndex((item) => item.id === id);
|
||||
const intent = mockIntents[index];
|
||||
if (!intent || intent.inherited || (intent.state !== "running" && intent.state !== "paused")) {
|
||||
throw new Error("Worker 状态已变化");
|
||||
}
|
||||
mockIntents.splice(index, 1);
|
||||
return {
|
||||
id: Number(id.replace(/\D/g, "")) || 0,
|
||||
state: "cancelled",
|
||||
deleted: {
|
||||
intents: 1,
|
||||
facts: 1,
|
||||
findings: 1,
|
||||
activities: D.activity.filter((item) => item.intent_id === id).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
const result = controlMockIntent(id, action);
|
||||
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
|
||||
return { id: Number(id.replace(/\D/g, "")) || 0, state: result.state };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 3 && seg[2] === "control" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
const result = controlMockTask(seg[1], action);
|
||||
if (!result.ok) throw new Error(result.error ?? "任务状态已变化");
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
return { id: seg[1], paused: Boolean(task?.paused), queued: Boolean(task?.queued), status: task?.status ?? "" };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "status") return { running: false };
|
||||
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
|
||||
if (path === "/active") {
|
||||
const id = String(b.id ?? mockActiveTask);
|
||||
@@ -227,21 +487,64 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
|
||||
// ── assets ──
|
||||
if (path === "/assets/counts") return D.assetCounts;
|
||||
if (path === "/assets/counts") return mockAssetCounts();
|
||||
if (path === "/assets" && m === "GET") {
|
||||
const type = q.get("type") ?? "";
|
||||
const list = type ? D.assets.filter((a) => a.type === type) : D.assets;
|
||||
const list = type ? mockAssets.filter((a) => a.type === type) : mockAssets;
|
||||
const limit = Number(q.get("limit") ?? 50);
|
||||
const offset = Number(q.get("offset") ?? 0);
|
||||
return { count: list.length, total: list.length, assets: list.slice(offset, offset + limit) };
|
||||
}
|
||||
if (path === "/assets" && m === "DELETE") return { deleted: (b.ids as unknown[])?.length ?? 0 };
|
||||
if (path === "/assets" && m === "DELETE") {
|
||||
const ids = new Set(Array.isArray(b.ids) ? b.ids.map(Number) : []);
|
||||
let deleted = 0;
|
||||
for (let index = mockAssets.length - 1; index >= 0; index--) {
|
||||
if (!ids.has(mockAssets[index].id)) continue;
|
||||
mockAssets.splice(index, 1);
|
||||
deleted++;
|
||||
}
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
// ── companies ──
|
||||
if (path === "/companies" && m === "GET") return D.companies;
|
||||
if (path === "/companies" && m === "POST") return { id: 2, created: true, scope_added: 0 };
|
||||
if (seg[0] === "companies" && seg[2] === "scope") return { added: 0, skipped: 0, invalid: 0 };
|
||||
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") return { deleted: 1, assets_deleted: 0 };
|
||||
if (path === "/companies" && m === "GET") return structuredClone(mockCompanies);
|
||||
if (path === "/companies" && m === "POST") {
|
||||
const name = String(b.name ?? "").trim();
|
||||
if (!name) throw new Error("企业名称不能为空");
|
||||
if (mockCompanies.some((company) => company.name.toLowerCase() === name.toLowerCase())) {
|
||||
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 };
|
||||
mockCompanies.push(company);
|
||||
return { id, created: true, scope_added: scope.length, scope_invalid: 0 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
const index = mockCompanies.findIndex((item) => item.id === id);
|
||||
if (index < 0) throw new Error("企业不存在");
|
||||
mockCompanies.splice(index, 1);
|
||||
let assetsDeleted = 0;
|
||||
for (let assetIndex = mockAssets.length - 1; assetIndex >= 0; assetIndex--) {
|
||||
const asset: Asset = mockAssets[assetIndex];
|
||||
if (asset.company_id !== id) continue;
|
||||
if (b.delete_assets === true) {
|
||||
mockAssets.splice(assetIndex, 1);
|
||||
assetsDeleted++;
|
||||
} else {
|
||||
delete asset.company_id;
|
||||
}
|
||||
}
|
||||
return { deleted: 1, assets_deleted: assetsDeleted };
|
||||
}
|
||||
|
||||
// ── exploration ──
|
||||
if (path === "/exploration/frontier") return D.frontier;
|
||||
@@ -267,6 +570,72 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings/groups") {
|
||||
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
let list = mockFindings.slice();
|
||||
const filterSeverity = q.get("severity");
|
||||
const filterStatus = q.get("status");
|
||||
const filterVulnclass = q.get("vulnclass");
|
||||
const filterTask = q.get("task_id");
|
||||
if (filterSeverity) list = list.filter((finding) => finding.severity === filterSeverity);
|
||||
if (filterStatus) list = list.filter((finding) => finding.status === filterStatus);
|
||||
if (filterVulnclass) list = list.filter((finding) => finding.vulnclass === filterVulnclass);
|
||||
if (filterTask === "__unassigned__") list = list.filter((finding) => !finding.task_id);
|
||||
else if (filterTask) list = list.filter((finding) => finding.task_id === filterTask);
|
||||
|
||||
const grouped = new Map<string, typeof list>();
|
||||
for (const finding of list) {
|
||||
const key = finding.task_id ?? "__unassigned__";
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), finding]);
|
||||
}
|
||||
const groups = Array.from(grouped, ([key, items]) => {
|
||||
const owner = mockTasks.find((candidate) => candidate.id === key);
|
||||
return {
|
||||
task_id: key === "__unassigned__" ? null : key,
|
||||
task_description: owner?.description ?? items[0]?.task_description ?? "",
|
||||
task_status: owner?.status ?? "",
|
||||
count: items.length,
|
||||
critical: items.filter((finding) => finding.severity === "critical").length,
|
||||
high: items.filter((finding) => finding.severity === "high").length,
|
||||
medium: items.filter((finding) => finding.severity === "medium").length,
|
||||
low: items.filter((finding) => finding.severity === "low").length,
|
||||
last_found_at: items.reduce((latest, finding) => (finding.ts > latest ? finding.ts : latest), ""),
|
||||
max_severity: Math.max(...items.map((finding) => severityOrder[finding.severity])),
|
||||
};
|
||||
});
|
||||
groups.sort((left, right) =>
|
||||
q.get("sort") === "severity"
|
||||
? right.max_severity - left.max_severity || right.last_found_at.localeCompare(left.last_found_at)
|
||||
: right.last_found_at.localeCompare(left.last_found_at),
|
||||
);
|
||||
const rawPage = Number(q.get("page") ?? 1);
|
||||
const rawPageSize = Number(q.get("limit") ?? 10);
|
||||
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(100, Math.floor(rawPageSize)) : 10;
|
||||
return {
|
||||
items: groups.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: groups.length,
|
||||
finding_total: list.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "deepen" && m === "POST") {
|
||||
const finding = mockFindings.find((candidate) => candidate.id === seg[2]);
|
||||
const description = String(b.description ?? "").trim();
|
||||
if (!description) throw new Error("description is required");
|
||||
if ([...description].length > 4000) throw new Error("description must be at most 4000 characters");
|
||||
if (!finding) throw new Error("finding not found");
|
||||
if (!finding.task_id || !mockTasks.some((candidate) => candidate.id === finding.task_id)) {
|
||||
throw new Error("finding origin task or node is no longer available");
|
||||
}
|
||||
return {
|
||||
task_id: finding.task_id,
|
||||
intent_id: `mock-deepen-${Date.now()}`,
|
||||
state: "open",
|
||||
queued: false,
|
||||
};
|
||||
}
|
||||
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") {
|
||||
const f = mockFindings.find((x) => x.id === seg[2]);
|
||||
@@ -320,14 +689,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (fSev) list = list.filter((f) => f.severity === fSev);
|
||||
if (fStatus) list = list.filter((f) => f.status === fStatus);
|
||||
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
|
||||
if (fTask) list = list.filter((f) => f.task_id === fTask);
|
||||
if (fTask === "__unassigned__") list = list.filter((f) => !f.task_id);
|
||||
else if (fTask) list = list.filter((f) => f.task_id === fTask);
|
||||
list.sort((a, b) =>
|
||||
q.get("sort") === "severity"
|
||||
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
|
||||
: +new Date(b.ts) - +new Date(a.ts),
|
||||
);
|
||||
const page = Number(q.get("page") ?? 1);
|
||||
const pageSize = Number(q.get("limit") ?? 20);
|
||||
const rawPage = Number(q.get("page") ?? 1);
|
||||
const rawPageSize = Number(q.get("limit") ?? 20);
|
||||
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(200, Math.floor(rawPageSize)) : 20;
|
||||
return {
|
||||
items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid),
|
||||
total: list.length,
|
||||
@@ -339,13 +711,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (q.has("page")) {
|
||||
const before = Number(q.get("before") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
let list = D.intents;
|
||||
let list = mockIntents;
|
||||
if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before);
|
||||
return { items: list.slice(0, limit), has_more: list.length > limit };
|
||||
}
|
||||
return D.intents;
|
||||
return mockIntents;
|
||||
}
|
||||
if (path === "/exploration/tokens") {
|
||||
const selectedTask = task ? mockTasks.find((item) => item.id === task) : undefined;
|
||||
return {
|
||||
workers: D.tokenWorkers,
|
||||
sessions: D.tokenSessions,
|
||||
total: selectedTask?.tokens ?? D.tokenTotal,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
|
||||
if (path === "/exploration/graph") return D.explorationGraph;
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
@@ -425,15 +804,43 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "agents" && seg[2] === "visibility" && m === "GET") return D.agentDetail(seg[1]).visibility;
|
||||
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
|
||||
if (path === "/conversations" && m === "POST")
|
||||
return {
|
||||
id: 3,
|
||||
if (path === "/conversations" && m === "GET") {
|
||||
sortMockConversations();
|
||||
return { conversations: structuredClone(mockConversations) };
|
||||
}
|
||||
if (path === "/conversations" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
const title = String(b.title ?? "").trim() || "新对话";
|
||||
const conversation: Conversation = {
|
||||
id: mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1,
|
||||
agent_key: String(b.agent_key ?? "mainagent"),
|
||||
title: String(b.title ?? "新会话"),
|
||||
created_at: "2026-07-26T04:00:00Z",
|
||||
updated_at: "2026-07-26T04:00:00Z",
|
||||
title,
|
||||
llm_profile_id: typeof b.llm_profile_id === "number" ? b.llm_profile_id : undefined,
|
||||
pinned: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockConversations.unshift(conversation);
|
||||
return structuredClone(conversation);
|
||||
}
|
||||
if (seg[0] === "conversations" && seg.length === 2 && m === "PATCH") {
|
||||
const conversation = mockConversations.find((item) => item.id === Number(seg[1]));
|
||||
if (!conversation) return {};
|
||||
if (typeof b.title === "string") conversation.title = b.title.trim();
|
||||
if (typeof b.pinned === "boolean") {
|
||||
conversation.pinned = b.pinned;
|
||||
conversation.pinned_at = b.pinned ? (conversation.pinned_at ?? new Date().toISOString()) : null;
|
||||
}
|
||||
conversation.updated_at = new Date().toISOString();
|
||||
sortMockConversations();
|
||||
return structuredClone(conversation);
|
||||
}
|
||||
if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockConversations.splice(index, 1);
|
||||
return { deleted: id };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
|
||||
+85
-4
@@ -30,6 +30,16 @@ export interface Task {
|
||||
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
|
||||
llm_failover_reason?: string;
|
||||
source_task_ids?: string[]; // directly related tasks inherited as read-only context
|
||||
company_ids?: number[]; // associated company asset scopes available as task context
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DeleteTaskOptions {
|
||||
@@ -237,13 +247,22 @@ export interface TaskScopeRow {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type CompanyScopeKind = "domain" | "ip" | "cidr" | "icp" | "keyword";
|
||||
|
||||
// 新增企业时提交的结构化资产范围规则。
|
||||
export interface CompanyScopeRule {
|
||||
kind: CompanyScopeKind;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
company_id: number;
|
||||
kind: "domain" | "ip" | "cidr";
|
||||
kind: CompanyScopeKind;
|
||||
domain?: string; // kind=domain 时有值
|
||||
net?: string; // kind=ip|cidr 时有值
|
||||
value?: string; // kind=icp|keyword 时可能由后端直接返回
|
||||
raw: string; // 原始用户输入,用于显示和回填
|
||||
reason?: string;
|
||||
}
|
||||
@@ -254,13 +273,13 @@ export interface Company {
|
||||
name: string;
|
||||
logo?: string; // 远程图标 URL;空则前端用名称首字母
|
||||
asset_count: number;
|
||||
scope: ScopeRow[];
|
||||
scope?: ScopeRow[];
|
||||
}
|
||||
|
||||
// ---- Exploration graph (per task) ----
|
||||
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
|
||||
export type GoalState = "open" | "met" | "abandoned";
|
||||
export type IntentState = "open" | "running" | "done" | "blocked" | "exhausted" | "stopped";
|
||||
export type IntentState = "open" | "running" | "paused" | "done" | "blocked" | "exhausted" | "stopped";
|
||||
export type FindingState = "confirmed" | "dismissed";
|
||||
export type HintState = "active" | "consumed";
|
||||
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
|
||||
@@ -326,6 +345,33 @@ export interface FindingsPage {
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface FindingGroup {
|
||||
task_id: string | number | null;
|
||||
task_description: string;
|
||||
task_status: string;
|
||||
count: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
last_found_at: string;
|
||||
}
|
||||
|
||||
export interface FindingGroupsPage {
|
||||
items: FindingGroup[];
|
||||
total: number;
|
||||
finding_total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface FindingDeepenResponse {
|
||||
task_id: string;
|
||||
intent_id: string;
|
||||
state: IntentState;
|
||||
queued: boolean;
|
||||
}
|
||||
|
||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||
export interface FindingStats {
|
||||
total: number;
|
||||
@@ -341,7 +387,7 @@ export interface FindingStats {
|
||||
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
|
||||
// 前端回退展示 id)及其漏洞条数。
|
||||
export interface FindingTaskOption {
|
||||
id: number;
|
||||
id: string | number;
|
||||
description: string;
|
||||
count: number;
|
||||
}
|
||||
@@ -417,6 +463,22 @@ export interface LLMTransition {
|
||||
next?: LLMAuditProfile;
|
||||
}
|
||||
|
||||
export interface TaskLLMResolution {
|
||||
profile_id?: number;
|
||||
name: string;
|
||||
format: string;
|
||||
model: string;
|
||||
source: "task_chain" | "agent_binding" | "global_profile" | "environment" | "global";
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TaskLLMResolutions {
|
||||
mainagent: TaskLLMResolution;
|
||||
planner: TaskLLMResolution;
|
||||
worker: TaskLLMResolution;
|
||||
}
|
||||
|
||||
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
|
||||
export interface AgentTrigger {
|
||||
id: number;
|
||||
@@ -444,6 +506,8 @@ export interface Conversation {
|
||||
agent_key: string;
|
||||
title: string;
|
||||
llm_profile_id?: number;
|
||||
pinned?: boolean;
|
||||
pinned_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -479,6 +543,23 @@ export interface TokenUsage {
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
export interface SessionTokenUsage {
|
||||
session: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
export interface BatchControlItem {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
status?: string;
|
||||
state?: string;
|
||||
queued?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Whole-task (all agents) token aggregate.
|
||||
export interface TokenTotal {
|
||||
input_tokens: number;
|
||||
|
||||
Reference in new issue
Block a user