修复BUG: 1、当存在较多任务时,系统会发生卡顿,加载对话或者加载界面时候存在明显卡顿,优化性能找出问题
This commit is contained in:
1 parent
cf0989c67d
commit
594436fddd
12 files changed
+576
-140
No files matched your search
@@ -991,6 +991,74 @@ func (d *DB) LastActivityAll() (map[int64]int64, error) {
|
|||||||
// GoalCounts is the goal summary for one exploration.
|
// GoalCounts is the goal summary for one exploration.
|
||||||
type GoalCounts struct{ Total, Met int }
|
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
|
// 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.
|
// for all tasks — used by listTasks so the task list shows progress for every task.
|
||||||
func (d *DB) GoalCountsAll() (map[int64]GoalCounts, error) {
|
func (d *DB) GoalCountsAll() (map[int64]GoalCounts, error) {
|
||||||
|
|||||||
@@ -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
|
-- 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.
|
-- 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);
|
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
|
-- C. LLM profiles
|
||||||
|
|||||||
+163
-3
@@ -52,9 +52,6 @@ func (d *DB) hydrateTaskContext(t *Task) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.LLMProfileID = legacyProfileID
|
|
||||||
t.ActiveLLMProfileID = activeProfileID
|
|
||||||
t.LLMChainRevision = revision
|
|
||||||
sources, err := d.TaskSourceIDs(t.ID)
|
sources, err := d.TaskSourceIDs(t.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -65,8 +62,22 @@ func (d *DB) hydrateTaskContext(t *Task) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.CompanyIDs = companies
|
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.LLMProfileIDs = make([]int64, 0, len(chain))
|
||||||
t.LLMFailoverState = "default"
|
t.LLMFailoverState = "default"
|
||||||
|
t.LLMFailoverReason = ""
|
||||||
var latest *TaskLLMProfile
|
var latest *TaskLLMProfile
|
||||||
activeReady := false
|
activeReady := false
|
||||||
for i := range chain {
|
for i := range chain {
|
||||||
@@ -90,6 +101,155 @@ func (d *DB) hydrateTaskContext(t *Task) error {
|
|||||||
if latest != nil {
|
if latest != nil {
|
||||||
t.LLMFailoverReason = latest.LastError
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -377,10 +377,8 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
for _, task := range out {
|
if err := d.hydrateTasksContext(out); err != nil {
|
||||||
if err := d.hydrateTaskContext(task); err != nil {
|
return nil, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-8
@@ -995,22 +995,20 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
active = t.ID
|
active = t.ID
|
||||||
}
|
}
|
||||||
list := s.m.List()
|
list := s.m.List()
|
||||||
toks, _ := s.m.PG().TokenTotalsAll() // whole-task token totals, one query for all tasks
|
metrics, _ := s.m.PG().TaskListMetricsAll()
|
||||||
lastAct, _ := s.m.PG().LastActivityAll() // persisted last-activity per task, one query
|
|
||||||
goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query
|
|
||||||
dtos := make([]TaskDTO, 0, len(list))
|
dtos := make([]TaskDTO, 0, len(list))
|
||||||
for _, t := range list {
|
for _, t := range list {
|
||||||
dto := taskDTO(t, s.resolvedTaskStatus(t))
|
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
|
// prefer the live in-memory heartbeat (fresher) and fall back to the
|
||||||
// persisted max activity time (survives restarts) for run-duration display.
|
// 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 {
|
if live := s.engine.LastActivity(t.ID); live > dto.LastActivity {
|
||||||
dto.LastActivity = live
|
dto.LastActivity = live
|
||||||
}
|
}
|
||||||
gc := goalCounts[t.ExpID]
|
dto.GoalsTotal = metric.Goals.Total
|
||||||
dto.GoalsTotal = gc.Total
|
dto.GoalsMet = metric.Goals.Met
|
||||||
dto.GoalsMet = gc.Met
|
|
||||||
dtos = append(dtos, dto)
|
dtos = append(dtos, dto)
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active})
|
writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active})
|
||||||
|
|||||||
@@ -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
|
// 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).
|
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
|
||||||
const HISTORY_PAGE = 200;
|
const HISTORY_PAGE = 200;
|
||||||
|
const CONVERSATION_LIST_PAGE = 100;
|
||||||
|
|
||||||
function conversationIsPinned(conversation: Conversation): boolean {
|
function conversationIsPinned(conversation: Conversation): boolean {
|
||||||
return conversation.pinned ?? Boolean(conversation.pinned_at);
|
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
|
// ConversationItem is one row in the left rail: title, agent subtitle, inline
|
||||||
// rename, pin marker, and a compact action menu.
|
// rename, pin marker, and a compact action menu.
|
||||||
function ConversationItem({
|
const ConversationItem = React.memo(function ConversationItem({
|
||||||
conv,
|
conv,
|
||||||
agent,
|
agent,
|
||||||
active,
|
active,
|
||||||
@@ -770,15 +771,15 @@ function ConversationItem({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
renameText: string;
|
renameText: string;
|
||||||
onSelect: () => void;
|
onSelect: (id: number) => void;
|
||||||
onStartRename: () => void;
|
onStartRename: (conversation: Conversation) => void;
|
||||||
onRenameText: (v: string) => void;
|
onRenameText: (v: string) => void;
|
||||||
onCommitRename: () => void;
|
onCommitRename: (id: number, title: string) => void;
|
||||||
onCancelRename: () => void;
|
onCancelRename: () => void;
|
||||||
onTogglePinned: () => void;
|
onTogglePinned: (conversation: Conversation) => void;
|
||||||
onDelete: () => void;
|
onDelete: (id: number) => void;
|
||||||
selectedForDelete: boolean;
|
selectedForDelete: boolean;
|
||||||
onSelectedForDeleteChange: (checked: boolean) => void;
|
onSelectedForDeleteChange: (id: number, checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
@@ -801,7 +802,7 @@ function ConversationItem({
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedForDelete}
|
checked={selectedForDelete}
|
||||||
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
|
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
|
||||||
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
||||||
className="ml-1 shrink-0"
|
className="ml-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
@@ -811,10 +812,10 @@ function ConversationItem({
|
|||||||
value={renameText}
|
value={renameText}
|
||||||
onChange={(e) => onRenameText(e.target.value)}
|
onChange={(e) => onRenameText(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!cancelRenameRef.current) onCommitRename();
|
if (!cancelRenameRef.current) onCommitRename(conv.id, renameText);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") onCommitRename();
|
if (e.key === "Enter") onCommitRename(conv.id, renameText);
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRenameRef.current = true;
|
cancelRenameRef.current = true;
|
||||||
@@ -826,8 +827,8 @@ function ConversationItem({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={() => onSelect(conv.id)}
|
||||||
onDoubleClick={onStartRename}
|
onDoubleClick={() => onStartRename(conv)}
|
||||||
title="双击重命名"
|
title="双击重命名"
|
||||||
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
|
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
|
||||||
>
|
>
|
||||||
@@ -864,11 +865,11 @@ function ConversationItem({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onSelect={onStartRename}>
|
<DropdownMenuItem onSelect={() => onStartRename(conv)}>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
重命名
|
重命名
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onTogglePinned}>
|
<DropdownMenuItem onSelect={() => onTogglePinned(conv)}>
|
||||||
{pinned ? <PinOffIcon /> : <PinIcon />}
|
{pinned ? <PinOffIcon /> : <PinIcon />}
|
||||||
{pinned ? "取消置顶" : "置顶"}
|
{pinned ? "取消置顶" : "置顶"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -890,13 +891,13 @@ function ConversationItem({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
|
<AlertDialogAction onClick={() => onDelete(conv.id)}>删除</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||||
@@ -908,6 +909,7 @@ export default function ChatPage() {
|
|||||||
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||||
const [bulkDeleting, setBulkDeleting] = 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
|
// Pending text + uploaded attachments handed off from a draft that created a
|
||||||
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
||||||
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
// 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);
|
window.history.replaceState(null, "", url);
|
||||||
}, [selectedId]);
|
}, [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,
|
// conversation agents: custom agents + conversational built-ins (role=assistant,
|
||||||
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
||||||
// are task-specific and stay hidden from the chat page.
|
// 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 selectedConversationCount = selectedConversationIds.size;
|
||||||
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
|
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
|
||||||
@@ -978,34 +985,37 @@ export default function ChatPage() {
|
|||||||
if (allConversationsSelected) conversationHeaderChecked = true;
|
if (allConversationsSelected) conversationHeaderChecked = true;
|
||||||
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
|
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
|
||||||
|
|
||||||
function toggleConversationSelected(id: number, checked: boolean) {
|
const toggleConversationSelected = React.useCallback((id: number, checked: boolean) => {
|
||||||
setSelectedConversationIds((current) => {
|
setSelectedConversationIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (checked) next.add(id);
|
if (checked) next.add(id);
|
||||||
else next.delete(id);
|
else next.delete(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
function toggleAllConversations(checked: boolean) {
|
function toggleAllConversations(checked: boolean) {
|
||||||
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(id: number) {
|
const del = React.useCallback(
|
||||||
try {
|
async (id: number) => {
|
||||||
await api.deleteConversation(id);
|
try {
|
||||||
if (selectedId === id) setSelectedId(null);
|
await api.deleteConversation(id);
|
||||||
setSelectedConversationIds((current) => {
|
setSelectedId((current) => (current === id ? null : current));
|
||||||
if (!current.has(id)) return current;
|
setSelectedConversationIds((current) => {
|
||||||
const next = new Set(current);
|
if (!current.has(id)) return current;
|
||||||
next.delete(id);
|
const next = new Set(current);
|
||||||
return next;
|
next.delete(id);
|
||||||
});
|
return next;
|
||||||
reloadConvs();
|
});
|
||||||
} catch (e) {
|
reloadConvs();
|
||||||
toast.error("删除失败:" + (e as Error).message);
|
} catch (e) {
|
||||||
}
|
toast.error("删除失败:" + (e as Error).message);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[reloadConvs],
|
||||||
|
);
|
||||||
|
|
||||||
async function deleteSelectedConversations() {
|
async function deleteSelectedConversations() {
|
||||||
const ids = [...selectedConversationIds];
|
const ids = [...selectedConversationIds];
|
||||||
@@ -1045,32 +1055,38 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePinned(conversation: Conversation) {
|
const togglePinned = React.useCallback(
|
||||||
const pinned = conversationIsPinned(conversation);
|
async (conversation: Conversation) => {
|
||||||
try {
|
const pinned = conversationIsPinned(conversation);
|
||||||
await api.pinConversation(conversation.id, !pinned);
|
try {
|
||||||
reloadConvs();
|
await api.pinConversation(conversation.id, !pinned);
|
||||||
} catch (e) {
|
reloadConvs();
|
||||||
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
|
} catch (e) {
|
||||||
}
|
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[reloadConvs],
|
||||||
|
);
|
||||||
|
|
||||||
function startRename(c: Conversation) {
|
const startRename = React.useCallback((c: Conversation) => {
|
||||||
setRenamingId(c.id);
|
setRenamingId(c.id);
|
||||||
setRenameText(c.title || "");
|
setRenameText(c.title || "");
|
||||||
}
|
}, []);
|
||||||
async function commitRename() {
|
const commitRename = React.useCallback(
|
||||||
const id = renamingId;
|
async (id: number, value: string) => {
|
||||||
const title = renameText.trim();
|
const title = value.trim();
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
if (!id || !title) return;
|
if (!title) return;
|
||||||
try {
|
try {
|
||||||
await api.renameConversation(id, title);
|
await api.renameConversation(id, title);
|
||||||
reloadConvs();
|
reloadConvs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("重命名失败:" + (e as Error).message);
|
toast.error("重命名失败:" + (e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[reloadConvs],
|
||||||
|
);
|
||||||
|
const cancelRename = React.useCallback(() => setRenamingId(null), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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!">
|
<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">
|
<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) => (
|
{visibleConversations.map((c) => (
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
key={c.id}
|
key={c.id}
|
||||||
conv={c}
|
conv={c}
|
||||||
agent={agents.find((a) => a.key === c.agent_key)}
|
agent={agentByKey.get(c.agent_key)}
|
||||||
active={selectedId === c.id}
|
active={selectedId === c.id}
|
||||||
renaming={renamingId === c.id}
|
renaming={renamingId === c.id}
|
||||||
renameText={renameText}
|
renameText={renamingId === c.id ? renameText : ""}
|
||||||
onSelect={() => setSelectedId(c.id)}
|
onSelect={setSelectedId}
|
||||||
onStartRename={() => startRename(c)}
|
onStartRename={startRename}
|
||||||
onRenameText={setRenameText}
|
onRenameText={setRenameText}
|
||||||
onCommitRename={commitRename}
|
onCommitRename={commitRename}
|
||||||
onCancelRename={() => setRenamingId(null)}
|
onCancelRename={cancelRename}
|
||||||
onTogglePinned={() => void togglePinned(c)}
|
onTogglePinned={togglePinned}
|
||||||
onDelete={() => del(c.id)}
|
onDelete={del}
|
||||||
selectedForDelete={selectedConversationIds.has(c.id)}
|
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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,13 +148,46 @@ export default function DashboardPage() {
|
|||||||
const [tools, setTools] = React.useState<Tool[]>([]);
|
const [tools, setTools] = React.useState<Tool[]>([]);
|
||||||
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
|
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(() => {
|
React.useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let loading = false;
|
||||||
|
let signature = "";
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const [tr, fr, sr, sets, pr, act, tok, ctok] = await Promise.all([
|
const tr = await api.tasks();
|
||||||
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.findings(),
|
||||||
api.stats(),
|
api.stats(),
|
||||||
api.settings(),
|
api.settings(),
|
||||||
@@ -164,23 +197,24 @@ export default function DashboardPage() {
|
|||||||
api.conversationTokens(),
|
api.conversationTokens(),
|
||||||
]);
|
]);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setTasks(tr.tasks);
|
setFindings(findings);
|
||||||
setFindings(fr);
|
setStats(stats);
|
||||||
setStats(sr);
|
setSettings(settings);
|
||||||
setSettings(sets);
|
setPending(pending);
|
||||||
setPending(pr);
|
setActivity(activity.items);
|
||||||
setActivity(act.items);
|
setTokens(tokens.total ?? null);
|
||||||
setTokens(tok.total ?? null);
|
setConvTokens(conversationTokens);
|
||||||
setConvTokens(ctok);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* transient errors — next poll retries */
|
// Preserve stale data and retry on the next interval.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
const t = setInterval(load, 5000);
|
const timer = setInterval(load, 5000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(t);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function scopeValue(row: TaskScopeRow): string {
|
|||||||
if (row.value) return row.value;
|
if (row.value) return row.value;
|
||||||
if (row.domain) return row.domain;
|
if (row.domain) return row.domain;
|
||||||
if (row.net) return row.net;
|
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 "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,17 +346,32 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
|
const [taskResp, statsResp, intentsResp, findingsResp] = await Promise.all([
|
||||||
api.tasks(),
|
api.task(taskId),
|
||||||
api.stats(taskId),
|
api.stats(taskId),
|
||||||
api.intents(taskId),
|
api.intents(taskId),
|
||||||
api.findings(taskId),
|
api.findings(taskId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
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);
|
setStats(statsResp);
|
||||||
setIntents(intentsResp);
|
setIntents(intentsResp);
|
||||||
setFindings(findingsResp);
|
setFindings(findingsResp);
|
||||||
@@ -372,10 +387,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// transient errors are ignored; the next poll will retry
|
// transient errors are ignored; the next poll will retry
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
void load();
|
||||||
void loadScope();
|
void loadScope();
|
||||||
void loadTokens();
|
void loadTokens();
|
||||||
void loadGoals();
|
void loadGoals();
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -26,30 +20,31 @@ import {
|
|||||||
PopoverTitle,
|
PopoverTitle,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { LLMProfile, Task } from "@/lib/types";
|
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 { AssetsTab } from "./_tabs/assets-tab";
|
||||||
import { CoverageGraphTab } from "./_tabs/coverage-graph-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 = [
|
const TABS = [
|
||||||
{ value: "sessions", label: "会话" },
|
{ value: "sessions", label: "会话" },
|
||||||
{ value: "overview", label: "总览" },
|
{ value: "overview", label: "总览" },
|
||||||
{ value: "graph", label: "探索链路" },
|
{ value: "graph", label: "探索链路" },
|
||||||
{ value: "findings", label: "发现" },
|
{ value: "findings", label: "发现" },
|
||||||
{ value: "assets", label: "测试资产" },
|
{ value: "assets", label: "测试资产" },
|
||||||
{ value: "coverage", label: "资产覆盖图" },
|
{ value: "coverage", label: "资产覆盖图" },
|
||||||
{ value: "intercept", label: "拦截审批" },
|
{ value: "intercept", label: "拦截审批" },
|
||||||
{ value: "report", label: "报告" },
|
{ value: "report", label: "报告" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function taskProfileIDs(task: Task): string[] {
|
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 activeProfile = profiles.find((profile) => profile.id === activeID);
|
||||||
const currentLabel = exhausted
|
const currentLabel = exhausted
|
||||||
? "配置链已耗尽"
|
? "配置链已耗尽"
|
||||||
: activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置");
|
: (activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置"));
|
||||||
const activeIndex = chain.indexOf(activeID);
|
const activeIndex = chain.indexOf(activeID);
|
||||||
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
|
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
|
||||||
const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
|
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>}
|
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent ref={popoverContentRef} align="start" className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4">
|
||||||
ref={popoverContentRef}
|
|
||||||
align="start"
|
|
||||||
className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4"
|
|
||||||
>
|
|
||||||
<PopoverHeader>
|
<PopoverHeader>
|
||||||
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
|
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
|
||||||
<PopoverDescription>{editorDescription}</PopoverDescription>
|
<PopoverDescription>{editorDescription}</PopoverDescription>
|
||||||
@@ -234,12 +225,16 @@ function TaskDetailInner() {
|
|||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const taskLoadInFlight = React.useRef<string | null>(null);
|
||||||
const load = React.useCallback(() => {
|
const load = React.useCallback(() => {
|
||||||
Promise.all([api.tasks(), api.stats(id).catch(() => null)])
|
if (taskLoadInFlight.current === id) return;
|
||||||
.then(([r, s]) => {
|
taskLoadInFlight.current = id;
|
||||||
const base = r.tasks.find((t) => t.id === id) ?? null;
|
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;
|
const at = s?.active_task;
|
||||||
if (base && at) {
|
if (at) {
|
||||||
base.in_flight = at.in_flight;
|
base.in_flight = at.in_flight;
|
||||||
base.goals_total = at.goals_total;
|
base.goals_total = at.goals_total;
|
||||||
base.goals_met = at.goals_met;
|
base.goals_met = at.goals_met;
|
||||||
@@ -247,12 +242,17 @@ function TaskDetailInner() {
|
|||||||
base.paused = at.paused;
|
base.paused = at.paused;
|
||||||
}
|
}
|
||||||
setTask(base);
|
setTask(base);
|
||||||
setPaused(at?.paused ?? base?.paused ?? false);
|
setPaused(at?.paused ?? base.paused ?? false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Keep the last rendered task state during a transient poll failure.
|
// 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]);
|
}, [id]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
+7
-2
@@ -188,6 +188,7 @@ export const api = {
|
|||||||
// ---- tasks ----
|
// ---- tasks ----
|
||||||
tasks: () =>
|
tasks: () =>
|
||||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
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: {
|
createTask: (input: {
|
||||||
name?: string;
|
name?: string;
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
@@ -283,7 +284,8 @@ export const api = {
|
|||||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||||
// ---- 目标管理(总览)----
|
// ---- 目标管理(总览)----
|
||||||
// 本任务全部目标(text/vulnclass/state)。
|
// 本任务全部目标(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、复活任务。
|
// 人工新增目标:写入图谱并通知 planner、复活任务。
|
||||||
addGoal: (id: string, text: string, vulnclass?: string) =>
|
addGoal: (id: string, text: string, vulnclass?: string) =>
|
||||||
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
|
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
|
||||||
@@ -295,7 +297,10 @@ export const api = {
|
|||||||
|
|
||||||
// ---- 操作约束管理(总览)----
|
// ---- 操作约束管理(总览)----
|
||||||
// 本任务全部操作约束(allow/deny)。
|
// 本任务全部操作约束(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,下一轮规划自然读到)。
|
// 新增约束(不通知 planner,下一轮规划自然读到)。
|
||||||
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
|
addConstraint: (id: string, text: string, kind: TaskConstraint["kind"]) =>
|
||||||
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
|
post<TaskConstraint>(`/tasks/${id}/constraints`, { text, kind }),
|
||||||
|
|||||||
@@ -490,6 +490,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
||||||
return { deleted: id };
|
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") {
|
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
|
||||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||||
if (!task) throw new Error("任务不存在");
|
if (!task) throw new Error("任务不存在");
|
||||||
|
|||||||
Reference in new issue
Block a user