diff --git a/db/exploration.go b/db/exploration.go index 3a8c563..f62e280 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -991,6 +991,74 @@ func (d *DB) LastActivityAll() (map[int64]int64, error) { // GoalCounts is the goal summary for one exploration. type GoalCounts struct{ Total, Met int } +// TaskListMetrics contains the aggregates rendered in task lists. +type TaskListMetrics struct { + Tokens TokenUsage + LastActivity int64 + Goals GoalCounts +} + +// TaskListMetricsAll returns list aggregates for every live task in one query. +// The lateral lookups use the per-exploration indexes instead of grouping the +// complete activity history on every poll. +func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) { + rows, err := d.Query(` + SELECT task.exploration_id, + COALESCE(token_metrics.input_tokens,0), + COALESCE(token_metrics.output_tokens,0), + COALESCE(token_metrics.cache_read_tokens,0), + COALESCE(token_metrics.cache_write_tokens,0), + COALESCE(latest_activity.created_at,0), + COALESCE(goal_metrics.total,0), + COALESCE(goal_metrics.met,0) + FROM tasks task + LEFT JOIN LATERAL ( + SELECT SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cache_read_tokens) AS cache_read_tokens, + SUM(cache_write_tokens) AS cache_write_tokens + FROM activity + WHERE exploration_id=task.exploration_id AND kind='result' + ) token_metrics ON true + LEFT JOIN LATERAL ( + SELECT EXTRACT(EPOCH FROM created_at)::bigint AS created_at + FROM activity + WHERE exploration_id=task.exploration_id + ORDER BY created_at DESC + LIMIT 1 + ) latest_activity ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE state='met') AS met + FROM exploration_nodes + WHERE exploration_id=task.exploration_id AND kind='goal' + ) goal_metrics ON true + WHERE task.deleted_at IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]TaskListMetrics{} + for rows.Next() { + var explorationID int64 + var metrics TaskListMetrics + if err := rows.Scan( + &explorationID, + &metrics.Tokens.InputTokens, + &metrics.Tokens.OutputTokens, + &metrics.Tokens.CacheReadTokens, + &metrics.Tokens.CacheWriteTokens, + &metrics.LastActivity, + &metrics.Goals.Total, + &metrics.Goals.Met, + ); err != nil { + return nil, err + } + out[explorationID] = metrics + } + return out, rows.Err() +} + // GoalCountsAll returns goal totals (total / met) per exploration id, one query // for all tasks — used by listTasks so the task list shows progress for every task. func (d *DB) GoalCountsAll() (map[int64]GoalCounts, error) { diff --git a/db/schema.sql b/db/schema.sql index b1a53fe..6d962d9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -271,6 +271,13 @@ CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id); -- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node -- can't distinguish them); this covers reverse pagination of those sessions. CREATE INDEX IF NOT EXISTS idx_act_worker ON activity(exploration_id, worker, id); +-- Task-list polls aggregate result usage and find the latest event repeatedly. +-- Cover the token columns for index-only aggregation and the timestamp order for +-- per-exploration latest-activity lookups. +CREATE INDEX IF NOT EXISTS idx_act_result_usage ON activity(exploration_id) + INCLUDE (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) + WHERE kind='result'; +CREATE INDEX IF NOT EXISTS idx_act_latest ON activity(exploration_id, created_at DESC); -- ===================================================================== -- C. LLM profiles diff --git a/db/task_context.go b/db/task_context.go index 14053d4..cf23c7c 100644 --- a/db/task_context.go +++ b/db/task_context.go @@ -52,9 +52,6 @@ func (d *DB) hydrateTaskContext(t *Task) error { if err != nil { return err } - t.LLMProfileID = legacyProfileID - t.ActiveLLMProfileID = activeProfileID - t.LLMChainRevision = revision sources, err := d.TaskSourceIDs(t.ID) if err != nil { return err @@ -65,8 +62,22 @@ func (d *DB) hydrateTaskContext(t *Task) error { return err } t.CompanyIDs = companies + applyTaskLLMContext(t, legacyProfileID, activeProfileID, revision, chain) + return nil +} + +func applyTaskLLMContext( + t *Task, + legacyProfileID, activeProfileID *int64, + revision int64, + chain []TaskLLMProfile, +) { + t.LLMProfileID = legacyProfileID + t.ActiveLLMProfileID = activeProfileID + t.LLMChainRevision = revision t.LLMProfileIDs = make([]int64, 0, len(chain)) t.LLMFailoverState = "default" + t.LLMFailoverReason = "" var latest *TaskLLMProfile activeReady := false for i := range chain { @@ -90,6 +101,155 @@ func (d *DB) hydrateTaskContext(t *Task) error { if latest != nil { t.LLMFailoverReason = latest.LastError } +} + +type taskBatchContext struct { + legacyProfileID *int64 + activeProfileID *int64 + revision int64 + chain []TaskLLMProfile + sourceIDs []int64 + companyIDs []int64 +} + +// hydrateTasksContext loads every task's LLM chain, source tasks, and company +// scopes with three bulk queries. ListTasks used to issue these queries once per +// task, making startup and task-list hydration grow as 3N+1 database round trips. +func (d *DB) hydrateTasksContext(tasks []*Task) error { + if len(tasks) == 0 { + return nil + } + + ids := make([]int64, 0, len(tasks)) + contexts := make(map[int64]*taskBatchContext, len(tasks)) + byID := make(map[int64]*Task, len(tasks)) + for _, task := range tasks { + if task == nil { + continue + } + ids = append(ids, task.ID) + contexts[task.ID] = &taskBatchContext{} + byID[task.ID] = task + } + if len(ids) == 0 { + return nil + } + + rows, err := d.Query(` +SELECT t.id, t.llm_profile_id, t.active_llm_profile_id, t.llm_chain_revision, + p.profile_id, p.position, p.status, COALESCE(p.last_error,''), p.exhausted_at +FROM tasks t +LEFT JOIN task_llm_profiles p ON p.task_id=t.id +WHERE t.id=ANY($1::bigint[]) +ORDER BY t.id, p.position NULLS LAST`, ids) + if err != nil { + return err + } + for rows.Next() { + var taskID, revision int64 + var legacy, active, profileID, position sql.NullInt64 + var status, lastError sql.NullString + var exhaustedAt sql.NullTime + if err := rows.Scan( + &taskID, &legacy, &active, &revision, + &profileID, &position, &status, &lastError, &exhaustedAt, + ); err != nil { + rows.Close() + return err + } + context := contexts[taskID] + if context == nil { + continue + } + context.revision = revision + if legacy.Valid { + id := legacy.Int64 + context.legacyProfileID = &id + } + if active.Valid { + id := active.Int64 + context.activeProfileID = &id + } + if profileID.Valid { + entry := TaskLLMProfile{ + ProfileID: profileID.Int64, + Position: int(position.Int64), + Status: status.String, + LastError: lastError.String, + } + if exhaustedAt.Valid { + ts := exhaustedAt.Time + entry.ExhaustedAt = &ts + } + context.chain = append(context.chain, entry) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + rows, err = d.Query(` +SELECT task_id, source_task_id +FROM task_relations +WHERE task_id=ANY($1::bigint[]) +ORDER BY task_id, created_at, source_task_id`, ids) + if err != nil { + return err + } + for rows.Next() { + var taskID, sourceID int64 + if err := rows.Scan(&taskID, &sourceID); err != nil { + rows.Close() + return err + } + if context := contexts[taskID]; context != nil { + context.sourceIDs = append(context.sourceIDs, sourceID) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + rows, err = d.Query(` +SELECT task_id, company_id +FROM task_scope +WHERE task_id=ANY($1::bigint[]) AND kind='company' AND company_id IS NOT NULL +ORDER BY task_id, id`, ids) + if err != nil { + return err + } + for rows.Next() { + var taskID, companyID int64 + if err := rows.Scan(&taskID, &companyID); err != nil { + rows.Close() + return err + } + if context := contexts[taskID]; context != nil { + context.companyIDs = append(context.companyIDs, companyID) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + for id, context := range contexts { + task := byID[id] + task.SourceTaskIDs = context.sourceIDs + task.CompanyIDs = context.companyIDs + applyTaskLLMContext( + task, + context.legacyProfileID, + context.activeProfileID, + context.revision, + context.chain, + ) + } return nil } diff --git a/db/task_list_performance_test.go b/db/task_list_performance_test.go new file mode 100644 index 0000000..104bedf --- /dev/null +++ b/db/task_list_performance_test.go @@ -0,0 +1,117 @@ +package db + +import ( + "fmt" + "testing" + "time" +) + +func TestListTasksBulkHydratesTaskContext(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + stamp := time.Now().UnixNano() + profileID, err := d.SaveProfile(&LLMProfile{ + Name: fmt.Sprintf("bulk-list-profile-%d", stamp), Format: "openai", Model: "test", APIKey: "test-key", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteProfile(profileID) }) + source, err := d.CreateTask(fmt.Sprintf("bulk-list-source-%d", stamp), "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(source.ID) }) + companyID, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Bulk List Company %d", stamp), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Companies().DeleteCompany(companyID) }) + child, err := d.CreateTaskWithOptions("bulk-list-child", "goal", TaskCreateOptions{ + LLMProfileIDs: []int64{profileID}, + SourceTaskIDs: []int64{source.ID}, + CompanyIDs: []int64{companyID}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(child.ID) }) + + tasks, err := d.ListTasks() + if err != nil { + t.Fatal(err) + } + var got *Task + for _, task := range tasks { + if task.ID == child.ID { + got = task + break + } + } + if got == nil { + t.Fatalf("task %d missing from list", child.ID) + } + if len(got.LLMProfileIDs) != 1 || got.LLMProfileIDs[0] != profileID || + got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileID || got.LLMFailoverState != "ready" { + t.Fatalf("LLM context was not bulk hydrated: %+v", got) + } + if len(got.SourceTaskIDs) != 1 || got.SourceTaskIDs[0] != source.ID { + t.Fatalf("source task context was not bulk hydrated: %v", got.SourceTaskIDs) + } + if len(got.CompanyIDs) != 1 || got.CompanyIDs[0] != companyID { + t.Fatalf("company context was not bulk hydrated: %v", got.CompanyIDs) + } +} + +func TestTaskListMetricsAll(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + task, err := d.CreateTask("task-list-metrics", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + + resultAt := time.Now().Add(-time.Second).Truncate(time.Second) + latestAt := resultAt.Add(time.Second) + if _, err := d.Exec(` +INSERT INTO activity( + exploration_id, kind, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, created_at +) VALUES ($1,'result',11,7,3,2,$2), ($1,'tool_result',999,999,999,999,$3)`, + task.ExplorationID, resultAt, latestAt); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(` +INSERT INTO exploration_nodes(exploration_id, kind, payload, state) +VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExplorationID); err != nil { + t.Fatal(err) + } + + all, err := d.TaskListMetricsAll() + if err != nil { + t.Fatal(err) + } + metrics, ok := all[task.ExplorationID] + if !ok { + t.Fatalf("metrics for exploration %d missing", task.ExplorationID) + } + if metrics.Tokens.InputTokens != 11 || metrics.Tokens.OutputTokens != 7 || + metrics.Tokens.CacheReadTokens != 3 || metrics.Tokens.CacheWriteTokens != 2 { + t.Fatalf("unexpected token metrics: %+v", metrics.Tokens) + } + if metrics.LastActivity != latestAt.Unix() { + t.Fatalf("last activity=%d, want %d", metrics.LastActivity, latestAt.Unix()) + } + if metrics.Goals.Total != 2 || metrics.Goals.Met != 1 { + t.Fatalf("unexpected goal metrics: %+v", metrics.Goals) + } +} diff --git a/db/tasks.go b/db/tasks.go index f658305..69a1a34 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -377,10 +377,8 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`) return nil, err } rows.Close() - for _, task := range out { - if err := d.hydrateTaskContext(task); err != nil { - return nil, err - } + if err := d.hydrateTasksContext(out); err != nil { + return nil, err } return out, nil } diff --git a/server/server.go b/server/server.go index 266c754..e46755a 100644 --- a/server/server.go +++ b/server/server.go @@ -995,22 +995,20 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { active = t.ID } list := s.m.List() - toks, _ := s.m.PG().TokenTotalsAll() // whole-task token totals, one query for all tasks - lastAct, _ := s.m.PG().LastActivityAll() // persisted last-activity per task, one query - goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query + metrics, _ := s.m.PG().TaskListMetricsAll() dtos := make([]TaskDTO, 0, len(list)) for _, t := range list { dto := taskDTO(t, s.resolvedTaskStatus(t)) - dto.Tokens = tokenTotalDTO(toks[t.ExpID]) + metric := metrics[t.ExpID] + dto.Tokens = tokenTotalDTO(metric.Tokens) // prefer the live in-memory heartbeat (fresher) and fall back to the // persisted max activity time (survives restarts) for run-duration display. - dto.LastActivity = lastAct[t.ExpID] + dto.LastActivity = metric.LastActivity if live := s.engine.LastActivity(t.ID); live > dto.LastActivity { dto.LastActivity = live } - gc := goalCounts[t.ExpID] - dto.GoalsTotal = gc.Total - dto.GoalsMet = gc.Met + dto.GoalsTotal = metric.Goals.Total + dto.GoalsMet = metric.Goals.Met dtos = append(dtos, dto) } writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active}) diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index 04a4616..57f6f0d 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -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(null); @@ -801,7 +802,7 @@ function ConversationItem({ > 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({ ) : ( + )} diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index df1b5b3..ec79ed8 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -148,13 +148,46 @@ export default function DashboardPage() { const [tools, setTools] = React.useState([]); const [llmProfiles, setLLMProfiles] = React.useState([]); - // 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); }; }, []); diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 37810d9..d7babef 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -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(); diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index b07434a..f28a12d 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -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 && +{backupCount}} - + 任务 LLM 配置链 {editorDescription} @@ -234,12 +225,16 @@ function TaskDetailInner() { }; }, [id]); + const taskLoadInFlight = React.useRef(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(); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9bb02e9..c28c328 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/tasks/${encodeURIComponent(id)}`), createTask: (input: { name?: string; categoryId?: number; @@ -283,7 +284,8 @@ export const api = { usageStats: (days = 365) => get(`/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(`/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(`/tasks/${id}/constraints`, { text, kind }), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 71ccc50..2b6f536 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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("任务不存在");