diff --git a/internal/app/app.go b/internal/app/app.go index a8cbc491..7cf5b079 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -443,6 +443,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error conversationHandler := handler.NewConversationHandler(db, log.Logger) conversationHandler.SetAudit(auditSvc) conversationHandler.SetTaskStopper(agentHandler) + conversationHandler.SetTaskStateProvider(agentHandler) auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger) robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger) robotHandler.SetAudit(auditSvc) @@ -1029,6 +1030,7 @@ func setupRoutes( protected.POST("/conversations", conversationHandler.CreateConversation) protected.GET("/conversations", conversationHandler.ListConversations) protected.GET("/conversations/:id", conversationHandler.GetConversation) + protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks) protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails) protected.GET("/process-details/:id", conversationHandler.GetProcessDetail) protected.PUT("/conversations/:id", conversationHandler.UpdateConversation) diff --git a/internal/database/plantask.go b/internal/database/plantask.go new file mode 100644 index 00000000..a64feaa8 --- /dev/null +++ b/internal/database/plantask.go @@ -0,0 +1,125 @@ +package database + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +// ConversationPlanTask mirrors the public fields persisted by Eino plantask. +// Keeping the transport model here avoids coupling the HTTP layer to Eino's +// private task type. +type ConversationPlanTask struct { + ID string `json:"id"` + Subject string `json:"subject"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Blocks []string `json:"blocks,omitempty"` + BlockedBy []string `json:"blockedBy,omitempty"` + ActiveForm string `json:"activeForm,omitempty"` + Owner string `json:"owner,omitempty"` +} + +// ListConversationPlanTasks returns the live Eino task board for one +// conversation. A missing task directory is the normal state for short or +// legacy conversations and therefore returns an empty list. +func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) { + return db.ListConversationPlanTasksSince(conversationID, time.Time{}) +} + +// ListConversationPlanTasksSince limits the board to files written during the +// current agent run. The Eino backend intentionally keeps older task files for +// model continuity, but the conversation UI must not surface those files before +// the new run has called TaskCreate. +func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) { + if db == nil { + return []ConversationPlanTask{}, nil + } + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" { + return nil, fmt.Errorf("conversation id is required") + } + base := strings.TrimSpace(db.einoPlantaskBaseDir) + if base == "" { + return []ConversationPlanTask{}, nil + } + + dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID)) + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []ConversationPlanTask{}, nil + } + if err != nil { + return nil, fmt.Errorf("read conversation plan tasks: %w", err) + } + + type numberedTask struct { + number int + task ConversationPlanTask + } + numbered := make([]numberedTask, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + idText := strings.TrimSuffix(entry.Name(), ".json") + number, parseErr := strconv.Atoi(idText) + if parseErr != nil || number < 1 { + continue + } + if !since.IsZero() { + info, infoErr := entry.Info() + if infoErr != nil { + continue + } + if info.ModTime().Before(since) { + continue + } + } + content, readErr := os.ReadFile(filepath.Join(dir, entry.Name())) + if readErr != nil { + if db.logger != nil { + db.logger.Debug("读取 Eino 任务文件失败", + zap.String("conversationId", conversationID), + zap.String("file", entry.Name()), + zap.Error(readErr)) + } + continue + } + var task ConversationPlanTask + if decodeErr := json.Unmarshal(content, &task); decodeErr != nil { + // TaskUpdate writes files concurrently with this read. A partial read + // is transient, so skip it and let the next poll recover. + if db.logger != nil { + db.logger.Debug("解析 Eino 任务文件失败", + zap.String("conversationId", conversationID), + zap.String("file", entry.Name()), + zap.Error(decodeErr)) + } + continue + } + if strings.TrimSpace(task.ID) == "" { + task.ID = idText + } + if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") { + continue + } + numbered = append(numbered, numberedTask{number: number, task: task}) + } + + sort.SliceStable(numbered, func(i, j int) bool { + return numbered[i].number < numbered[j].number + }) + tasks := make([]ConversationPlanTask, 0, len(numbered)) + for _, item := range numbered { + tasks = append(tasks, item.task) + } + return tasks, nil +} diff --git a/internal/database/plantask_test.go b/internal/database/plantask_test.go new file mode 100644 index 00000000..6efe59e4 --- /dev/null +++ b/internal/database/plantask_test.go @@ -0,0 +1,104 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) { + tmp := t.TempDir() + db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + base := filepath.Join(tmp, "skills", ".eino", "plantask") + db.SetEinoConversationDirs(base, "", "", "") + missing, err := db.ListConversationPlanTasks("missing") + if err != nil || len(missing) != 0 { + t.Fatalf("missing task board = %#v, err=%v", missing, err) + } + + dir := filepath.Join(base, "conversation-1") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + files := map[string]string{ + "10.json": `{"id":"10","subject":"最后检查","status":"pending"}`, + "2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`, + "1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`, + "bad.json": `{`, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", name, err) + } + } + if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil { + t.Fatalf("WriteFile(highwatermark): %v", err) + } + + tasks, err := db.ListConversationPlanTasks("conversation-1") + if err != nil { + t.Fatalf("ListConversationPlanTasks: %v", err) + } + if len(tasks) != 3 { + t.Fatalf("tasks = %#v, want 3", tasks) + } + if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" { + t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID) + } + if tasks[1].ActiveForm != "正在实现接口" { + t.Fatalf("activeForm = %q", tasks[1].ActiveForm) + } +} + +func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) { + tmp := t.TempDir() + db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + base := filepath.Join(tmp, "plantask") + db.SetEinoConversationDirs(base, "", "", "") + dir := filepath.Join(base, "conversation-current-run") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + oldPath := filepath.Join(dir, "1.json") + if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil { + t.Fatalf("WriteFile(old): %v", err) + } + runStartedAt := time.Now().Add(-time.Second) + oldTime := runStartedAt.Add(-time.Minute) + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { + t.Fatalf("Chtimes(old): %v", err) + } + + tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt) + if err != nil { + t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err) + } + if len(tasks) != 0 { + t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks) + } + + newPath := filepath.Join(dir, "2.json") + if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil { + t.Fatalf("WriteFile(new): %v", err) + } + tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt) + if err != nil { + t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err) + } + if len(tasks) != 1 || tasks[0].ID != "2" { + t.Fatalf("current tasks = %#v, want task 2 only", tasks) + } +} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 5a5c0437..b1581d07 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -230,6 +230,20 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) { } } +// ConversationTaskRuntimeState exposes the authoritative live state and start +// time used to scope persisted TaskCreate files to the current run. A task +// already entering cancellation must stop driving progress UI immediately. +func (h *AgentHandler) ConversationTaskRuntimeState(conversationID string) (bool, time.Time) { + if h == nil || h.tasks == nil || strings.TrimSpace(conversationID) == "" { + return false, time.Time{} + } + task := h.tasks.GetTaskSnapshot(strings.TrimSpace(conversationID)) + if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), "running") { + return false, time.Time{} + } + return true, task.StartedAt +} + func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) { if h == nil || h.agent == nil { return diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index b3716f20..36c0515f 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" @@ -18,12 +19,20 @@ type ConversationTaskStopper interface { CancelRunningTaskForConversation(conversationID string) } +// ConversationTaskStateProvider reports whether the in-memory agent task for +// a conversation is still genuinely running. Plan files may survive a service +// restart or cancellation, so their status alone is not authoritative. +type ConversationTaskStateProvider interface { + ConversationTaskRuntimeState(conversationID string) (running bool, startedAt time.Time) +} + // ConversationHandler 对话处理器 type ConversationHandler struct { db *database.DB logger *zap.Logger audit *audit.Service taskStopper ConversationTaskStopper + taskState ConversationTaskStateProvider } // SetAudit wires platform audit logging. @@ -36,6 +45,12 @@ func (h *ConversationHandler) SetTaskStopper(stopper ConversationTaskStopper) { h.taskStopper = stopper } +// SetTaskStateProvider wires the live agent task registry used by supplemental +// conversation UI such as the agent-maintained plan list. +func (h *ConversationHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) { + h.taskState = provider +} + // NewConversationHandler 创建新的对话处理器 func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler { return &ConversationHandler{ @@ -206,6 +221,70 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) { c.JSON(http.StatusOK, conv) } +// GetConversationPlanTasks returns the task list maintained by the agent's +// TaskCreate/TaskUpdate tools for this conversation. +func (h *ConversationHandler) GetConversationPlanTasks(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + session, ok := security.CurrentSession(c) + if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对话"}) + return + } + if _, err := h.db.GetConversationLite(id); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"}) + return + } + running := false + startedAt := time.Time{} + if h.taskState != nil { + running, startedAt = h.taskState.ConversationTaskRuntimeState(id) + } + if !running { + c.JSON(http.StatusOK, gin.H{ + "tasks": []database.ConversationPlanTask{}, "total": 0, + "completed": 0, "activeStep": 0, "running": false, + }) + return + } + tasks, err := h.db.ListConversationPlanTasksSince(id, startedAt) + if err != nil { + h.logger.Error("获取对话任务列表失败", zap.String("conversationId", id), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取任务列表失败"}) + return + } + + completed := 0 + activeStep := 0 + for i, task := range tasks { + status := strings.ToLower(strings.TrimSpace(task.Status)) + if status == "completed" { + completed++ + } + if activeStep == 0 && status == "in_progress" { + activeStep = i + 1 + } + } + if activeStep == 0 { + for i, task := range tasks { + if strings.ToLower(strings.TrimSpace(task.Status)) != "completed" { + activeStep = i + 1 + break + } + } + } + if activeStep == 0 && len(tasks) > 0 { + activeStep = len(tasks) + } + + c.JSON(http.StatusOK, gin.H{ + "tasks": tasks, + "total": len(tasks), + "completed": completed, + "activeStep": activeStep, + "running": true, + }) +} + const ( defaultProcessDetailsPageLimit = 50 maxProcessDetailsPageLimit = 500 diff --git a/internal/handler/conversation_plantask_test.go b/internal/handler/conversation_plantask_test.go new file mode 100644 index 00000000..0162be34 --- /dev/null +++ b/internal/handler/conversation_plantask_test.go @@ -0,0 +1,151 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type staticConversationTaskState struct { + running bool + startedAt time.Time +} + +func (s staticConversationTaskState) ConversationTaskRuntimeState(string) (bool, time.Time) { + return s.running, s.startedAt +} + +func TestGetConversationPlanTasksRequiresAccessAndReportsProgress(t *testing.T) { + gin.SetMode(gin.TestMode) + tmp := t.TempDir() + db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + conversation, err := db.CreateConversation("plan", database.ConversationCreateMeta{}) + if err != nil { + t.Fatalf("CreateConversation: %v", err) + } + user, err := db.CreateRBACUser("plan-user", "Plan User", "hash", true, nil) + if err != nil { + t.Fatalf("CreateRBACUser: %v", err) + } + base := filepath.Join(tmp, "plantask") + db.SetEinoConversationDirs(base, "", "", "") + dir := filepath.Join(base, conversation.ID) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + for name, content := range map[string]string{ + "1.json": `{"id":"1","subject":"完成项","status":"completed"}`, + "2.json": `{"id":"2","subject":"当前项","status":"in_progress"}`, + "3.json": `{"id":"3","subject":"等待项","status":"pending"}`, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + + handler := NewConversationHandler(db, zap.NewNop()) + handler.SetTaskStateProvider(staticConversationTaskState{running: true}) + request := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil) + c.Params = gin.Params{{Key: "id", Value: conversation.ID}} + c.Set(security.ContextSessionKey, security.Session{ + UserID: user.ID, + Scope: database.RBACScopeAssigned, + }) + handler.GetConversationPlanTasks(c) + return w + } + + w := request() + if w.Code != http.StatusForbidden { + t.Fatalf("unassigned status = %d, want %d", w.Code, http.StatusForbidden) + } + if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil { + t.Fatalf("AssignResourceToUser: %v", err) + } + w = request() + if w.Code != http.StatusOK { + t.Fatalf("assigned status = %d: %s", w.Code, w.Body.String()) + } + var response struct { + Total int `json:"total"` + Completed int `json:"completed"` + ActiveStep int `json:"activeStep"` + Tasks []database.ConversationPlanTask `json:"tasks"` + Running bool `json:"running"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Total != 3 || response.Completed != 1 || response.ActiveStep != 2 || !response.Running { + t.Fatalf("progress = %#v", response) + } +} + +func TestGetConversationPlanTasksReportsStoppedLiveTask(t *testing.T) { + gin.SetMode(gin.TestMode) + tmp := t.TempDir() + db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask-stopped.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + conversation, err := db.CreateConversation("stopped plan", database.ConversationCreateMeta{}) + if err != nil { + t.Fatalf("CreateConversation: %v", err) + } + user, err := db.CreateRBACUser("stopped-plan-user", "Stopped Plan User", "hash", true, nil) + if err != nil { + t.Fatalf("CreateRBACUser: %v", err) + } + if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil { + t.Fatalf("AssignResourceToUser: %v", err) + } + base := filepath.Join(tmp, "plantask") + db.SetEinoConversationDirs(base, "", "", "") + dir := filepath.Join(base, conversation.ID) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "1.json"), []byte(`{"id":"1","subject":"残留项","status":"in_progress"}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + handler := NewConversationHandler(db, zap.NewNop()) + handler.SetTaskStateProvider(staticConversationTaskState{running: false}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil) + c.Params = gin.Params{{Key: "id", Value: conversation.ID}} + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned}) + handler.GetConversationPlanTasks(c) + if w.Code != http.StatusOK { + t.Fatalf("status = %d: %s", w.Code, w.Body.String()) + } + var response struct { + Running bool `json:"running"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Running || response.Total != 0 { + t.Fatalf("response = %#v", response) + } +} diff --git a/web/static/css/chat-plan-progress.css b/web/static/css/chat-plan-progress.css new file mode 100644 index 00000000..da561fb5 --- /dev/null +++ b/web/static/css/chat-plan-progress.css @@ -0,0 +1,203 @@ +/* Agent-maintained plan: compact progress chip, expanded on hover/focus. */ +.agent-plan-progress { + --agent-plan-surface: var(--card-bg); + --agent-plan-surface-hover: var(--bg-tertiary); + --agent-plan-text: var(--text-primary); + --agent-plan-text-secondary: var(--text-secondary); + --agent-plan-border: var(--border-color); + --agent-plan-spinner-track: color-mix(in srgb, var(--accent-color) 30%, transparent); + --agent-plan-trigger-bottom: 14px; + --agent-plan-panel-bottom: 70px; + position: relative; + z-index: 42; + flex: 0 0 0; + width: 100%; + height: 0; + display: flex; + justify-content: center; + pointer-events: none; +} + +.agent-plan-progress[hidden] { + display: none; +} + +.agent-plan-progress-trigger { + position: absolute; + right: 50%; + bottom: var(--agent-plan-trigger-bottom); + transform: translateX(50%); + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 46px; + padding: 0 18px; + border: 1px solid var(--agent-plan-border); + border-radius: 18px; + color: var(--agent-plan-text); + background: var(--agent-plan-surface); + box-shadow: var(--shadow-lg); + font: inherit; + font-size: 15px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; + transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.agent-plan-progress-trigger:hover, +.agent-plan-progress-trigger:focus-visible { + color: var(--agent-plan-text); + background: var(--agent-plan-surface-hover); + border-color: var(--accent-color); + outline: none; +} + +.agent-plan-progress-panel { + position: absolute; + right: 50%; + bottom: var(--agent-plan-panel-bottom); + width: max-content; + min-width: 360px; + max-width: min(560px, calc(100vw - 40px)); + max-height: min(54vh, 440px); + padding: 14px 18px; + overflow: auto; + border: 1px solid var(--agent-plan-border); + border-radius: 18px; + color: var(--agent-plan-text); + background: var(--agent-plan-surface); + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translate(50%, 8px) scale(0.985); + transform-origin: bottom center; + visibility: hidden; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; +} + +/* + * “回到最新消息”只在用户离开底部时出现。它与任务进度同为居中浮层, + * 两者同时可见时让任务进度上移,保留回到底部按钮靠近输入框的位置。 + */ +.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) { + --agent-plan-trigger-bottom: 64px; + --agent-plan-panel-bottom: 120px; +} + +.agent-plan-progress.is-hover-active .agent-plan-progress-panel, +.agent-plan-progress:focus-within .agent-plan-progress-panel, +.agent-plan-progress.is-open .agent-plan-progress-panel { + opacity: 1; + transform: translate(50%, 0) scale(1); + visibility: visible; + pointer-events: auto; +} + +.agent-plan-task { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: start; + gap: 10px; + min-height: 34px; + padding: 4px 0; + font-size: 15px; + font-weight: 570; + line-height: 1.45; +} + +.agent-plan-task-label { + min-width: 0; + overflow-wrap: anywhere; +} + +.agent-plan-task--completed { + color: var(--agent-plan-text-secondary); +} + +.agent-plan-task--in_progress { + color: var(--agent-plan-text); +} + +.agent-plan-task--pending { + color: var(--agent-plan-text-secondary); +} + +.agent-plan-task-status { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-top: 2px; + border: 2px solid var(--agent-plan-text-secondary); + border-radius: 50%; + box-sizing: border-box; + flex: 0 0 auto; +} + +.agent-plan-task-status--completed { + border-color: var(--agent-plan-text-secondary); + color: var(--agent-plan-text-secondary); +} + +.agent-plan-task-check { + width: 13px; + height: 13px; + fill: none; + stroke: currentColor; + stroke-width: 2.3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-plan-task-status--in_progress { + border-color: var(--agent-plan-spinner-track); + border-top-color: var(--accent-color); + animation: agent-plan-spin 820ms linear infinite; +} + +.agent-plan-progress-trigger .agent-plan-task-status { + margin-top: 0; +} + +@keyframes agent-plan-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 640px) { + .agent-plan-progress { + --agent-plan-trigger-bottom: 10px; + --agent-plan-panel-bottom: 66px; + } + + .chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) { + --agent-plan-trigger-bottom: 60px; + --agent-plan-panel-bottom: 116px; + } + + .agent-plan-progress-panel { + min-width: min(360px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); + padding: 12px 15px; + } + + .agent-plan-progress-trigger { + min-height: 42px; + padding: 0 15px; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-plan-progress-trigger, + .agent-plan-progress-panel { + transition: none; + } + + .agent-plan-task-status--in_progress { + animation-duration: 1.8s; + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 65f9db8d..175971b8 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3489,6 +3489,7 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { .chat-container.is-conversation-restoring #chat-messages, .chat-container.is-conversation-restoring #chat-turn-rail, .chat-container.is-conversation-restoring #chat-return-latest, +.chat-container.is-conversation-restoring #agent-plan-progress, .chat-container.is-conversation-restoring #chat-input-container { visibility: hidden; } @@ -3546,7 +3547,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { .chat-messages:hover::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--text-muted) 80%, transparent); } - .chat-welcome-empty-state { flex: 1 0 auto; width: 100%; @@ -45316,6 +45316,12 @@ html[data-theme="dark"] .conversation-sidebar .recent-conversations-section { container-type: inline-size; } +/* The composer is an isolated stacking context. Lift that whole context while the + settings popover is open so the turn rail and its preview cannot paint above it. */ +.chat-input-container.is-session-settings-open { + z-index: 121; +} + .chat-composer-context { position: relative; z-index: 1; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 53eb9bcb..19b45ee6 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -562,6 +562,10 @@ "conversationPreviewViewed": "Viewed", "conversationPreviewConversation": "Conversation", "returnToLatest": "Jump to latest message", + "taskProgressStep": "Step {{current}} of {{total}}", + "taskProgressOpen": "View task progress", + "taskProgressDetails": "Task progress details", + "taskProgressUnnamed": "Untitled task", "completedUnread": "Completed, not viewed", "newConversationInProject": "Start a new conversation in this project", "newUnassignedConversation": "Start a new conversation without a project", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 86c47a85..6caedb5a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -550,6 +550,10 @@ "conversationPreviewViewed": "已查看", "conversationPreviewConversation": "对话", "returnToLatest": "回到最新消息", + "taskProgressStep": "第 {{current}} / {{total}} 步", + "taskProgressOpen": "查看任务进度", + "taskProgressDetails": "任务进度详情", + "taskProgressUnnamed": "未命名任务", "completedUnread": "已完成,尚未查看", "newConversationInProject": "在此项目中新建对话", "newUnassignedConversation": "新建无项目对话", diff --git a/web/static/js/chat-codex-layout.test.cjs b/web/static/js/chat-codex-layout.test.cjs index d2d22d8c..f39bc496 100644 --- a/web/static/js/chat-codex-layout.test.cjs +++ b/web/static/js/chat-codex-layout.test.cjs @@ -42,3 +42,10 @@ test('欢迎语随项目和无项目状态更新', () => { assert.equal(zh.chat.welcomeSubtitle, '请输入您的测试需求,系统将自动执行相应的安全测试。'); assert.equal(typeof en.chat.projectWelcomeMessage, 'string'); }); + +test('会话设置打开时提升整个输入区层级并遮住轮次导航', () => { + assert.match(chat, /function syncChatSessionSettingsLayerState\(\)/); + assert.match(chat, /inputBar\.classList\.toggle\('is-session-settings-open', open\)/); + assert.match(styles, /\.chat-input-container\.is-session-settings-open\s*\{[\s\S]*?z-index:\s*121/); + assert.match(styles, /\.chat-turn-rail\s*\{[\s\S]*?z-index:\s*20/); +}); diff --git a/web/static/js/chat-plan-progress.js b/web/static/js/chat-plan-progress.js new file mode 100644 index 00000000..2f8d4f30 --- /dev/null +++ b/web/static/js/chat-plan-progress.js @@ -0,0 +1,316 @@ +(function (root) { + 'use strict'; + + const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']); + const POLL_INTERVAL_MS = 1500; + const FINAL_STATE_HOLD_MS = 2400; + + function normalizeTask(raw, index) { + const task = raw && typeof raw === 'object' ? raw : {}; + const status = String(task.status || 'pending').trim().toLowerCase(); + return { + id: String(task.id || (index + 1)), + subject: String(task.subject || '').trim(), + description: String(task.description || '').trim(), + activeForm: String(task.activeForm || '').trim(), + status: ['pending', 'in_progress', 'completed', 'deleted'].includes(status) ? status : 'pending' + }; + } + + function deriveProgress(rawTasks) { + const tasks = (Array.isArray(rawTasks) ? rawTasks : []) + .map(normalizeTask) + .filter((task) => task.status !== 'deleted'); + let activeIndex = tasks.findIndex((task) => task.status === 'in_progress'); + if (activeIndex < 0) activeIndex = tasks.findIndex((task) => task.status !== 'completed'); + if (activeIndex < 0 && tasks.length) activeIndex = tasks.length - 1; + return { + tasks, + total: tasks.length, + completed: tasks.filter((task) => task.status === 'completed').length, + activeStep: activeIndex >= 0 ? activeIndex + 1 : 0, + allCompleted: tasks.length > 0 && tasks.every((task) => task.status === 'completed') + }; + } + + function applyTaskUpdate(rawTasks, args) { + const update = args && typeof args === 'object' ? args : {}; + const taskID = String(update.taskId || update.taskID || '').trim(); + if (!taskID) return deriveProgress(rawTasks).tasks; + return deriveProgress(rawTasks).tasks + .filter((task) => !(String(update.status || '').toLowerCase() === 'deleted' && task.id === taskID)) + .map((task) => { + if (task.id !== taskID) return task; + return Object.assign({}, task, { + subject: String(update.subject || task.subject).trim(), + description: String(update.description || task.description).trim(), + activeForm: String(update.activeForm || task.activeForm).trim(), + status: String(update.status || task.status).trim().toLowerCase() + }); + }); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { normalizeTask, deriveProgress, applyTaskUpdate }; + return; + } + + const state = { + conversationId: '', + tasks: [], + signature: '', + expanded: false, + requestSequence: 0, + abortController: null, + pollTimer: null, + refreshTimer: null, + finalHoldUntil: 0, + taskCalls: new Map() + }; + + const host = root.document && root.document.getElementById('agent-plan-progress'); + if (!host) return; + + let passiveHoverAnchor = null; + + function clearPassiveHoverVisual() { + host.classList.remove('is-hover-active'); + } + + function resetPassiveHover() { + clearPassiveHoverVisual(); + passiveHoverAnchor = null; + } + + function disarmPassiveHover(event) { + clearPassiveHoverVisual(); + if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return; + passiveHoverAnchor = { x: event.clientX, y: event.clientY }; + } + + function armHoverAfterPointerMove(event) { + if (event && event.pointerType && event.pointerType !== 'mouse') return; + if (passiveHoverAnchor) { + if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return; + // Smooth scrolling and layout shifts may emit pointermove without the + // physical pointer moving. Keep the panel locked until coordinates change. + if (event.clientX === passiveHoverAnchor.x && event.clientY === passiveHoverAnchor.y) return; + passiveHoverAnchor = null; + } + host.classList.add('is-hover-active'); + } + + host.addEventListener('pointermove', armHoverAfterPointerMove); + host.addEventListener('pointerleave', clearPassiveHoverVisual); + const returnLatestButton = root.document.getElementById('chat-return-latest'); + if (returnLatestButton) { + // Clicking the return-to-latest control moves this plan chip downward. + // Clear the hover gate before that layout shift so a stationary pointer + // cannot accidentally reveal the plan panel underneath it. + returnLatestButton.addEventListener('pointerdown', disarmPassiveHover); + returnLatestButton.addEventListener('click', disarmPassiveHover); + } + + function translate(key, fallback, params) { + if (typeof root.t === 'function') { + const value = root.t(key, params || {}); + if (value && value !== key) return value; + } + let value = fallback; + Object.entries(params || {}).forEach(([name, replacement]) => { + value = value.replaceAll('{{' + name + '}}', String(replacement)); + }); + return value; + } + + function createSVG(className, pathData) { + const namespace = 'http://www.w3.org/2000/svg'; + const svg = root.document.createElementNS(namespace, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add(className); + const path = root.document.createElementNS(namespace, 'path'); + path.setAttribute('d', pathData); + svg.appendChild(path); + return svg; + } + + function statusIcon(status) { + const icon = root.document.createElement('span'); + icon.className = 'agent-plan-task-status agent-plan-task-status--' + status; + icon.setAttribute('aria-hidden', 'true'); + if (status === 'completed') { + icon.appendChild(createSVG('agent-plan-task-check', 'M7.5 12.5 10.5 15.5 16.8 8.8')); + } + return icon; + } + + function taskLabel(task) { + if (task.status === 'in_progress' && task.activeForm) return task.activeForm; + return task.subject || translate('chat.taskProgressUnnamed', '未命名任务'); + } + + function render(force) { + const progress = deriveProgress(state.tasks); + const signature = JSON.stringify(progress.tasks) + '|' + state.expanded; + if (!force && signature === state.signature) return; + state.signature = signature; + host.replaceChildren(); + if (!progress.total) { + host.hidden = true; + resetPassiveHover(); + return; + } + host.hidden = false; + host.classList.toggle('is-open', state.expanded); + + const panel = root.document.createElement('div'); + panel.className = 'agent-plan-progress-panel'; + panel.id = 'agent-plan-progress-panel'; + panel.setAttribute('role', 'status'); + panel.setAttribute('aria-label', translate('chat.taskProgressDetails', '任务进度详情')); + progress.tasks.forEach((task) => { + const row = root.document.createElement('div'); + row.className = 'agent-plan-task agent-plan-task--' + task.status; + row.appendChild(statusIcon(task.status)); + const label = root.document.createElement('span'); + label.className = 'agent-plan-task-label'; + label.textContent = taskLabel(task); + if (task.description) label.title = task.description; + row.appendChild(label); + panel.appendChild(row); + }); + + const trigger = root.document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'agent-plan-progress-trigger'; + trigger.setAttribute('aria-controls', panel.id); + trigger.setAttribute('aria-expanded', state.expanded ? 'true' : 'false'); + trigger.setAttribute('aria-label', translate('chat.taskProgressOpen', '查看任务进度')); + trigger.appendChild(statusIcon(progress.allCompleted ? 'completed' : 'in_progress')); + const count = root.document.createElement('span'); + count.className = 'agent-plan-progress-count'; + count.textContent = translate('chat.taskProgressStep', '第 {{current}} / {{total}} 步', { + current: progress.activeStep, + total: progress.total + }); + trigger.appendChild(count); + trigger.addEventListener('click', () => { + state.expanded = !state.expanded; + render(true); + if (state.expanded) host.querySelector('.agent-plan-progress-trigger')?.focus(); + }); + + host.append(panel, trigger); + } + + function currentConversationId() { + return String(root.currentConversationId || '').trim(); + } + + function setConversation(conversationId) { + const next = String(conversationId || '').trim(); + if (next === state.conversationId) return false; + state.conversationId = next; + state.tasks = []; + state.signature = ''; + state.expanded = false; + state.finalHoldUntil = 0; + state.taskCalls.clear(); + resetPassiveHover(); + state.requestSequence += 1; + if (state.abortController) state.abortController.abort(); + state.abortController = null; + render(true); + return true; + } + + async function fetchPlanTasks() { + const conversationId = currentConversationId(); + setConversation(conversationId); + if (!conversationId || root.document.hidden) return; + const sequence = ++state.requestSequence; + if (state.abortController) state.abortController.abort(); + const controller = new AbortController(); + state.abortController = controller; + try { + const fetcher = typeof root.apiFetch === 'function' ? root.apiFetch : root.fetch.bind(root); + const response = await fetcher('/api/conversations/' + encodeURIComponent(conversationId) + '/plan-tasks', { + signal: controller.signal + }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const payload = await response.json(); + if (sequence !== state.requestSequence || conversationId !== state.conversationId) return; + if (payload && payload.running === false) { + state.tasks = []; + state.expanded = false; + state.finalHoldUntil = 0; + render(false); + return; + } + const tasks = deriveProgress(payload && payload.tasks).tasks; + if (!tasks.length && state.tasks.length && Date.now() < state.finalHoldUntil) return; + state.tasks = tasks; + render(false); + } catch (error) { + if (error && error.name === 'AbortError') return; + // A task list is supplemental UI; a transient poll failure must not + // interfere with the chat or erase the last known task state. + } finally { + if (sequence === state.requestSequence) state.abortController = null; + } + } + + function scheduleRefresh(delay) { + root.clearTimeout(state.refreshTimer); + state.refreshTimer = root.setTimeout(fetchPlanTasks, Number(delay) || 0); + } + + function handlePlanToolEvent(event) { + const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {}; + const conversationId = String(detail.conversationId || '').trim(); + if (!conversationId || conversationId !== currentConversationId()) return; + const data = detail.data && typeof detail.data === 'object' ? detail.data : {}; + const toolName = String(data.toolName || '').trim().toLowerCase(); + if (!PLAN_TOOL_NAMES.has(toolName)) return; + const callId = String(data.toolCallId || '').trim(); + if (detail.eventType === 'tool_call') { + if (callId) state.taskCalls.set(callId, { toolName, args: data.argumentsObj || {} }); + scheduleRefresh(60); + return; + } + if (detail.eventType !== 'tool_result') return; + const call = callId ? state.taskCalls.get(callId) : null; + if (callId) state.taskCalls.delete(callId); + if (data.success !== false && call && call.toolName === 'taskupdate') { + state.tasks = applyTaskUpdate(state.tasks, call.args); + const progress = deriveProgress(state.tasks); + if (progress.allCompleted) state.finalHoldUntil = Date.now() + FINAL_STATE_HOLD_MS; + render(false); + } + scheduleRefresh(100); + } + + root.addEventListener('agent-plan-task-event', handlePlanToolEvent); + root.addEventListener('conversation-changed', (event) => { + setConversation(event && event.detail ? event.detail.conversationId : currentConversationId()); + scheduleRefresh(0); + }); + root.document.addEventListener('visibilitychange', () => { + if (!root.document.hidden) scheduleRefresh(0); + }); + root.document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !state.expanded) return; + state.expanded = false; + render(true); + }); + root.document.addEventListener('pointerdown', (event) => { + if (!state.expanded || host.contains(event.target)) return; + state.expanded = false; + render(true); + }); + + setConversation(currentConversationId()); + fetchPlanTasks(); + state.pollTimer = root.setInterval(fetchPlanTasks, POLL_INTERVAL_MS); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/web/static/js/chat-plan-progress.test.cjs b/web/static/js/chat-plan-progress.test.cjs new file mode 100644 index 00000000..395036af --- /dev/null +++ b/web/static/js/chat-plan-progress.test.cjs @@ -0,0 +1,74 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const { deriveProgress, applyTaskUpdate } = require('./chat-plan-progress.js'); + +test('任务进度优先定位进行中步骤并保留完成项', () => { + const progress = deriveProgress([ + { id: '1', subject: '梳理需求', status: 'completed' }, + { id: '2', subject: '实现组件', activeForm: '正在实现组件', status: 'in_progress' }, + { id: '3', subject: '浏览器验证', status: 'pending' } + ]); + assert.equal(progress.activeStep, 2); + assert.equal(progress.completed, 1); + assert.equal(progress.total, 3); + assert.equal(progress.allCompleted, false); +}); + +test('TaskUpdate 成功后即时勾选,最终步骤显示全部完成', () => { + const initial = [ + { id: '1', subject: '接口', status: 'completed' }, + { id: '2', subject: '界面', status: 'in_progress' } + ]; + const updated = applyTaskUpdate(initial, { taskId: '2', status: 'completed' }); + const progress = deriveProgress(updated); + assert.equal(progress.activeStep, 2); + assert.equal(progress.completed, 2); + assert.equal(progress.allCompleted, true); +}); + +test('删除任务不会出现在悬浮清单中', () => { + const tasks = applyTaskUpdate([ + { id: '1', subject: '保留', status: 'pending' }, + { id: '2', subject: '删除', status: 'pending' } + ], { taskId: '2', status: 'deleted' }); + assert.deepEqual(tasks.map((task) => task.id), ['1']); +}); + +test('任务进度样式跟随系统主题变量而非固定深色', () => { + const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8'); + assert.match(css, /--agent-plan-surface:\s*var\(--card-bg\)/); + assert.match(css, /background:\s*var\(--agent-plan-surface\)/); + assert.match(css, /color:\s*var\(--agent-plan-text\)/); + assert.doesNotMatch(css, /background:\s*#(?:292929|2b2b2b|303030)/i); +}); + +test('回到最新按钮与任务进度同时显示时采用上下避让布局', () => { + const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8'); + const template = fs.readFileSync('web/templates/index.html', 'utf8'); + assert.match(css, /\.chat-return-latest:not\(\[hidden\]\)\s*\+\s*\.agent-plan-progress:not\(\[hidden\]\)/); + assert.match(css, /--agent-plan-trigger-bottom:\s*64px/); + assert.match(css, /--agent-plan-panel-bottom:\s*120px/); + assert.match(css, /bottom:\s*var\(--agent-plan-trigger-bottom\)/); + assert.match(css, /bottom:\s*var\(--agent-plan-panel-bottom\)/); + assert.match(template, /]+id="chat-return-latest"[\s\S]*?<\/button>\s*
{ + const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8'); + const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8'); + assert.doesNotMatch(css, /\.agent-plan-progress:hover\s+\.agent-plan-progress-panel/); + assert.match(css, /\.agent-plan-progress\.is-hover-active\s+\.agent-plan-progress-panel/); + assert.match(source, /host\.addEventListener\('pointermove',\s*armHoverAfterPointerMove\)/); + assert.match(source, /returnLatestButton\.addEventListener\('pointerdown',\s*disarmPassiveHover\)/); + assert.match(source, /passiveHoverAnchor = \{ x: event\.clientX, y: event\.clientY \}/); + assert.match(source, /event\.clientX === passiveHoverAnchor\.x && event\.clientY === passiveHoverAnchor\.y/); + assert.match(source, /host\.classList\.remove\('is-hover-active'\)/); +}); + +test('服务端判定任务停止后立即清空旧任务卡片', () => { + const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8'); + assert.match(source, /payload && payload\.running === false/); + assert.match(source, /state\.tasks = \[\][\s\S]{0,160}state\.expanded = false/); +}); diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 60b7ca70..44a5af50 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -342,7 +342,7 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状 assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/); assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/); assert.match(html, /router\.js\?v=20260813-2/); - assert.match(html, /chat\.js\?v=20260813-3/); + assert.match(html, /chat\.js\?v=20260813-4/); }); test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => { @@ -394,5 +394,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => { const css = fs.readFileSync('web/static/css/style.css', 'utf8'); assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/); assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/); - assert.match(html, /style\.css\?v=20260813-5/); + assert.match(html, /style\.css\?v=20260813-6/); }); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index f98d8f20..fcfce803 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -314,6 +314,16 @@ function mountChatSessionSettingsPopover() { composerSurface.appendChild(wrap); } wrap.classList.add('chat-session-settings-popover'); + syncChatSessionSettingsLayerState(); +} + +function syncChatSessionSettingsLayerState() { + const wrap = document.getElementById('chat-reasoning-wrapper'); + const inputBar = document.getElementById('chat-input-container'); + if (!inputBar) return; + const open = !!wrap && wrap.style.display !== 'none' && + !wrap.classList.contains('conversation-reasoning-collapsed'); + inputBar.classList.toggle('is-session-settings-open', open); } function initChatReasoningBarHeightSync() { @@ -1574,6 +1584,7 @@ function openChatSessionSettings(section, event) { if (!wrap || !toggle || wrap.style.display === 'none') return; syncChatReasoningBarHeight(); wrap.classList.remove('conversation-reasoning-collapsed'); + syncChatSessionSettingsLayerState(); toggle.setAttribute('aria-expanded', 'true'); if (typeof closeAgentModePanel === 'function') closeAgentModePanel(); if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel(); @@ -1732,6 +1743,7 @@ function closeChatReasoningPanel() { const wrap = document.getElementById('chat-reasoning-wrapper'); const toggle = document.getElementById('conversation-reasoning-toggle'); if (wrap) wrap.classList.add('conversation-reasoning-collapsed'); + syncChatSessionSettingsLayerState(); if (toggle) toggle.setAttribute('aria-expanded', 'false'); } @@ -1741,6 +1753,7 @@ function toggleConversationReasoningCard() { if (!wrap || !toggle) return; syncChatReasoningBarHeight(); wrap.classList.toggle('conversation-reasoning-collapsed'); + syncChatSessionSettingsLayerState(); const collapsed = wrap.classList.contains('conversation-reasoning-collapsed'); toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); if (!collapsed) { @@ -5434,6 +5447,7 @@ async function startNewConversation(options = {}) { try { window.currentConversationId = ''; } catch (e) { /* ignore */ } + window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: '' } })); updateChatPrimaryActionState(); currentConversationGroupId = null; // 新对话不属于任何分组 // 顶部“新任务”继承当前文件夹;文件夹内的“+”仍可显式指定(包括无项目)。 @@ -5945,6 +5959,7 @@ async function loadConversation(conversationId) { try { window.currentConversationId = conversationId; } catch (e) { /* ignore */ } + window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: conversationId } })); updateChatPrimaryActionState(); if (typeof refreshChatProjectSelector === 'function') { refreshChatProjectSelector({ reloadFolders: false, renderFolders: false }); diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index ce858a4e..8d42ebb2 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -246,8 +246,8 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状 assert.match(chat, /signal: conversationLoadController\.signal/); assert.match(template, /monitor\.js\?v=20260813-9/); assert.match(template, /chat-scroll\.js\?v=20260813-6/); - assert.match(template, /chat\.js\?v=20260813-3/); - assert.match(template, /style\.css\?v=20260813-5/); + assert.match(template, /chat\.js\?v=20260813-4/); + assert.match(template, /style\.css\?v=20260813-6/); }); test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => { diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 18eb5ca6..fd984ebf 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -32,6 +32,7 @@ function setCurrentConversationIdFromStream(cid) { currentConversationId = cid; try { window.currentConversationId = cid; + window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: cid } })); if (typeof window.syncChatConversationHash === 'function') { window.syncChatConversationHash(cid); } @@ -2591,6 +2592,22 @@ function formatEinoRunRetryTitle(data) { return base; } +function dispatchAgentPlanTaskEvent(event, fallbackConversationId) { + if (!event || (event.type !== 'tool_call' && event.type !== 'tool_result')) return; + const data = event.data && typeof event.data === 'object' ? event.data : {}; + const toolName = String(data.toolName || '').trim().toLowerCase(); + if (!['taskcreate', 'taskupdate', 'tasklist', 'taskget'].includes(toolName)) return; + const conversationId = String(data.conversationId || fallbackConversationId || window.currentConversationId || '').trim(); + if (!conversationId) return; + window.dispatchEvent(new CustomEvent('agent-plan-task-event', { + detail: { + eventType: event.type, + conversationId: conversationId, + data: data + } + })); +} + // 处理流式事件 function handleStreamEvent(event, progressElement, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) { @@ -2619,6 +2636,7 @@ function handleStreamEvent(event, progressElement, progressId, return; } } + dispatchAgentPlanTaskEvent(event, expectedConversationId || eventConversationId); const streamScrollWasPinned = typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true); diff --git a/web/templates/index.html b/web/templates/index.html index 192b4c75..eb531b75 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -32,6 +32,7 @@ })(); + @@ -1180,6 +1181,7 @@ +
@@ -6820,7 +6822,8 @@ - + +