修复BUG: 1、当存在较多任务时,系统会发生卡顿,加载对话或者加载界面时候存在明显卡顿,优化性能找出问题

This commit is contained in:
pphua committed 2026-08-28 08:00:36 +08:00
1 parent cf0989c67d
commit 594436fddd
12 files changed
+576 -140

No files matched your search

+94 -67
View File
@@ -80,6 +80,7 @@ function fmtDuration(ms: number): string {
// then one more page each time the user scrolls to the top. Kept modest so a long
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
const HISTORY_PAGE = 200;
const CONVERSATION_LIST_PAGE = 100;
function conversationIsPinned(conversation: Conversation): boolean {
return conversation.pinned ?? Boolean(conversation.pinned_at);
@@ -749,7 +750,7 @@ function ChatView({
// ConversationItem is one row in the left rail: title, agent subtitle, inline
// rename, pin marker, and a compact action menu.
function ConversationItem({
const ConversationItem = React.memo(function ConversationItem({
conv,
agent,
active,
@@ -770,15 +771,15 @@ function ConversationItem({
active: boolean;
renaming: boolean;
renameText: string;
onSelect: () => void;
onStartRename: () => void;
onSelect: (id: number) => void;
onStartRename: (conversation: Conversation) => void;
onRenameText: (v: string) => void;
onCommitRename: () => void;
onCommitRename: (id: number, title: string) => void;
onCancelRename: () => void;
onTogglePinned: () => void;
onDelete: () => void;
onTogglePinned: (conversation: Conversation) => void;
onDelete: (id: number) => void;
selectedForDelete: boolean;
onSelectedForDeleteChange: (checked: boolean) => void;
onSelectedForDeleteChange: (id: number, checked: boolean) => void;
}) {
const [deleteOpen, setDeleteOpen] = React.useState(false);
const renameInputRef = React.useRef<HTMLInputElement>(null);
@@ -801,7 +802,7 @@ function ConversationItem({
>
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
@@ -811,10 +812,10 @@ function ConversationItem({
value={renameText}
onChange={(e) => onRenameText(e.target.value)}
onBlur={() => {
if (!cancelRenameRef.current) onCommitRename();
if (!cancelRenameRef.current) onCommitRename(conv.id, renameText);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename();
if (e.key === "Enter") onCommitRename(conv.id, renameText);
if (e.key === "Escape") {
e.preventDefault();
cancelRenameRef.current = true;
@@ -826,8 +827,8 @@ function ConversationItem({
) : (
<button
type="button"
onClick={onSelect}
onDoubleClick={onStartRename}
onClick={() => onSelect(conv.id)}
onDoubleClick={() => onStartRename(conv)}
title="双击重命名"
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
>
@@ -864,11 +865,11 @@ function ConversationItem({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onStartRename}>
<DropdownMenuItem onSelect={() => onStartRename(conv)}>
<PencilIcon />
重命名
</DropdownMenuItem>
<DropdownMenuItem onSelect={onTogglePinned}>
<DropdownMenuItem onSelect={() => onTogglePinned(conv)}>
{pinned ? <PinOffIcon /> : <PinIcon />}
{pinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
@@ -890,13 +891,13 @@ function ConversationItem({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
<AlertDialogAction onClick={() => onDelete(conv.id)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
});
export default function ChatPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
@@ -908,6 +909,7 @@ export default function ChatPage() {
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false);
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
// Pending text + uploaded attachments handed off from a draft that created a
// conversation via the paperclip, keyed by the new conversation id (consumed once
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
@@ -965,11 +967,16 @@ export default function ChatPage() {
window.history.replaceState(null, "", url);
}, [selectedId]);
const selected = convs.find((c) => c.id === selectedId) ?? null;
const selected = React.useMemo(() => convs.find((c) => c.id === selectedId) ?? null, [convs, selectedId]);
const agentByKey = React.useMemo(() => new Map(agents.map((agent) => [agent.key, agent])), [agents]);
const visibleConversations = React.useMemo(
() => convs.slice(0, visibleConversationCount),
[convs, visibleConversationCount],
);
// conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
// are task-specific and stay hidden from the chat page.
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
const chatAgents = React.useMemo(() => agents.filter((a) => !a.builtin || a.role === "assistant"), [agents]);
const selectedConversationCount = selectedConversationIds.size;
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
@@ -978,34 +985,37 @@ export default function ChatPage() {
if (allConversationsSelected) conversationHeaderChecked = true;
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
function toggleConversationSelected(id: number, checked: boolean) {
const toggleConversationSelected = React.useCallback((id: number, checked: boolean) => {
setSelectedConversationIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
}, []);
function toggleAllConversations(checked: boolean) {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
}
async function del(id: number) {
try {
await api.deleteConversation(id);
if (selectedId === id) setSelectedId(null);
setSelectedConversationIds((current) => {
if (!current.has(id)) return current;
const next = new Set(current);
next.delete(id);
return next;
});
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
const del = React.useCallback(
async (id: number) => {
try {
await api.deleteConversation(id);
setSelectedId((current) => (current === id ? null : current));
setSelectedConversationIds((current) => {
if (!current.has(id)) return current;
const next = new Set(current);
next.delete(id);
return next;
});
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
},
[reloadConvs],
);
async function deleteSelectedConversations() {
const ids = [...selectedConversationIds];
@@ -1045,32 +1055,38 @@ 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}`);
}
}
const togglePinned = React.useCallback(
async (conversation: Conversation) => {
const pinned = conversationIsPinned(conversation);
try {
await api.pinConversation(conversation.id, !pinned);
reloadConvs();
} catch (e) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
}
},
[reloadConvs],
);
function startRename(c: Conversation) {
const startRename = React.useCallback((c: Conversation) => {
setRenamingId(c.id);
setRenameText(c.title || "");
}
async function commitRename() {
const id = renamingId;
const title = renameText.trim();
setRenamingId(null);
if (!id || !title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
}
}, []);
const commitRename = React.useCallback(
async (id: number, value: string) => {
const title = value.trim();
setRenamingId(null);
if (!title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
},
[reloadConvs],
);
const cancelRename = React.useCallback(() => setRenamingId(null), []);
return (
<div
@@ -1111,25 +1127,36 @@ export default function ChatPage() {
<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.map((c) => (
{visibleConversations.map((c) => (
<ConversationItem
key={c.id}
conv={c}
agent={agents.find((a) => a.key === c.agent_key)}
agent={agentByKey.get(c.agent_key)}
active={selectedId === c.id}
renaming={renamingId === c.id}
renameText={renameText}
onSelect={() => setSelectedId(c.id)}
onStartRename={() => startRename(c)}
renameText={renamingId === c.id ? renameText : ""}
onSelect={setSelectedId}
onStartRename={startRename}
onRenameText={setRenameText}
onCommitRename={commitRename}
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)}
onCancelRename={cancelRename}
onTogglePinned={togglePinned}
onDelete={del}
selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
onSelectedForDeleteChange={toggleConversationSelected}
/>
))}
{visibleConversationCount < convs.length && (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 w-full"
onClick={() => setVisibleConversationCount((count) => count + CONVERSATION_LIST_PAGE)}
>
加载更多
</Button>
)}
</div>
</ScrollArea>
</div>
+48 -14
View File
@@ -148,13 +148,46 @@ export default function DashboardPage() {
const [tools, setTools] = React.useState<Tool[]>([]);
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
// fast poll: tasks, findings, stats, pending, activity (every 5s)
// The task list is the most expensive dashboard source. Poll it independently
// so a large history cannot hold back every other dashboard panel.
React.useEffect(() => {
let alive = true;
let loading = false;
let signature = "";
const load = async () => {
if (loading) return;
loading = true;
try {
const [tr, fr, sr, sets, pr, act, tok, ctok] = await Promise.all([
api.tasks(),
const tr = await api.tasks();
if (!alive) return;
const nextSignature = JSON.stringify(tr.tasks);
if (nextSignature !== signature) {
signature = nextSignature;
setTasks(tr.tasks);
}
} catch {
/* transient errors — next poll retries */
} finally {
loading = false;
}
};
void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
// The remaining fast sources stay batched so one poll causes a single render.
React.useEffect(() => {
let alive = true;
let loading = false;
const load = async () => {
if (loading) return;
loading = true;
try {
const [findings, stats, settings, pending, activity, tokens, conversationTokens] = await Promise.all([
api.findings(),
api.stats(),
api.settings(),
@@ -164,23 +197,24 @@ export default function DashboardPage() {
api.conversationTokens(),
]);
if (!alive) return;
setTasks(tr.tasks);
setFindings(fr);
setStats(sr);
setSettings(sets);
setPending(pr);
setActivity(act.items);
setTokens(tok.total ?? null);
setConvTokens(ctok);
setFindings(findings);
setStats(stats);
setSettings(settings);
setPending(pending);
setActivity(activity.items);
setTokens(tokens.total ?? null);
setConvTokens(conversationTokens);
} catch {
/* transient errors — next poll retries */
// Preserve stale data and retry on the next interval.
} finally {
loading = false;
}
};
void load();
const t = setInterval(load, 5000);
const timer = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
clearInterval(timer);
};
}, []);
@@ -56,7 +56,7 @@ function scopeValue(row: TaskScopeRow): string {
if (row.value) return row.value;
if (row.domain) return row.domain;
if (row.net) return row.net;
if (row.company_id) return row.company_name || `企业 #${row.company_id}`;
if (row.company_id) return row.company_name?.trim() ? row.company_name : `企业 #${row.company_id}`;
return "—";
}
@@ -346,17 +346,32 @@ export function OverviewTab({ taskId }: { taskId: string }) {
React.useEffect(() => {
let cancelled = false;
let loading = false;
const load = async () => {
if (loading) return;
loading = true;
try {
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
api.tasks(),
const [taskResp, statsResp, intentsResp, findingsResp] = await Promise.all([
api.task(taskId),
api.stats(taskId),
api.intents(taskId),
api.findings(taskId),
]);
if (cancelled) return;
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
const activeTask = statsResp.active_task;
setTask(
activeTask
? {
...taskResp,
in_flight: activeTask.in_flight,
goals_total: activeTask.goals_total,
goals_met: activeTask.goals_met,
engine_mode: statsResp.engine_mode ?? activeTask.engine_mode,
paused: activeTask.paused,
}
: taskResp,
);
setStats(statsResp);
setIntents(intentsResp);
setFindings(findingsResp);
@@ -372,10 +387,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
});
} catch {
// transient errors are ignored; the next poll will retry
} finally {
loading = false;
}
};
load();
void load();
void loadScope();
void loadTokens();
void loadGoals();
@@ -1,22 +1,16 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeftIcon,
PauseIcon,
PlayIcon,
BrainIcon,
CheckIcon,
CircleAlertIcon,
} from "lucide-react";
import { ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -26,30 +20,31 @@ import {
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { LLMProfile, Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { ReportTab } from "./_tabs/report-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { AssetsTab } from "./_tabs/assets-tab";
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { ReportTab } from "./_tabs/report-tab";
import { SessionsTab } from "./_tabs/sessions-tab";
const TABS = [
{ value: "sessions", label: "会话" },
{ value: "overview", label: "总览" },
{ value: "graph", label: "探索链路" },
{ value: "findings", label: "发现" },
{ value: "assets", label: "测试资产" },
{ value: "coverage", label: "资产覆盖图" },
{ value: "sessions", label: "会话" },
{ value: "overview", label: "总览" },
{ value: "graph", label: "探索链路" },
{ value: "findings", label: "发现" },
{ value: "assets", label: "测试资产" },
{ value: "coverage", label: "资产覆盖图" },
{ value: "intercept", label: "拦截审批" },
{ value: "report", label: "报告" },
{ value: "report", label: "报告" },
];
function taskProfileIDs(task: Task): string[] {
@@ -81,7 +76,7 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
const activeProfile = profiles.find((profile) => profile.id === activeID);
const currentLabel = exhausted
? "配置链已耗尽"
: activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置");
: (activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置"));
const activeIndex = chain.indexOf(activeID);
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
@@ -151,11 +146,7 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent
ref={popoverContentRef}
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4"
>
<PopoverContent ref={popoverContentRef} align="start" className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4">
<PopoverHeader>
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
<PopoverDescription>{editorDescription}</PopoverDescription>
@@ -234,12 +225,16 @@ function TaskDetailInner() {
};
}, [id]);
const taskLoadInFlight = React.useRef<string | null>(null);
const load = React.useCallback(() => {
Promise.all([api.tasks(), api.stats(id).catch(() => null)])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
if (taskLoadInFlight.current === id) return;
taskLoadInFlight.current = id;
Promise.all([api.task(id), api.stats(id).catch(() => null)])
.then(([task, s]) => {
if (taskLoadInFlight.current !== id) return;
const base = { ...task };
const at = s?.active_task;
if (base && at) {
if (at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
@@ -247,12 +242,17 @@ function TaskDetailInner() {
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
setPaused(at?.paused ?? base.paused ?? false);
})
.catch(() => {
// Keep the last rendered task state during a transient poll failure.
})
.finally(() => setLoaded(true));
.finally(() => {
if (taskLoadInFlight.current === id) {
taskLoadInFlight.current = null;
setLoaded(true);
}
});
}, [id]);
React.useEffect(() => {
load();
+7 -2
View File
@@ -188,6 +188,7 @@ export const api = {
// ---- tasks ----
tasks: () =>
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
task: (id: string) => get<Task>(`/tasks/${encodeURIComponent(id)}`),
createTask: (input: {
name?: string;
categoryId?: number;
@@ -283,7 +284,8 @@ export const api = {
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
// ---- 目标管理(总览)----
// 本任务全部目标(text/vulnclass/state)。
taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`),
taskGoals: (id: string) =>
get<{ goals: TaskGoal[] | null }>(`/tasks/${id}/goals`).then((response) => ({ goals: arr(response.goals) })),
// 人工新增目标:写入图谱并通知 planner、复活任务。
addGoal: (id: string, text: string, vulnclass?: string) =>
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
@@ -295,7 +297,10 @@ export const api = {
// ---- 操作约束管理(总览)----
// 本任务全部操作约束(allow/deny)。
taskConstraints: (id: string) => get<{ constraints: TaskConstraint[] }>(`/tasks/${id}/constraints`),
taskConstraints: (id: string) =>
get<{ constraints: TaskConstraint[] | null }>(`/tasks/${id}/constraints`).then((response) => ({
constraints: arr(response.constraints),
})),
// 新增约束(不通知 planner,下一轮规划自然读到)。
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
+5
View File
@@ -490,6 +490,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (index >= 0) mockTaskTemplates.splice(index, 1);
return { deleted: id };
}
if (seg[0] === "tasks" && seg.length === 2 && m === "GET") {
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");
return structuredClone(task);
}
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");