mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 15:10:20 +02:00
feat(ui): 显示 Agent 任务进度列表 (#251)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -550,6 +550,10 @@
|
||||
"conversationPreviewViewed": "已查看",
|
||||
"conversationPreviewConversation": "对话",
|
||||
"returnToLatest": "回到最新消息",
|
||||
"taskProgressStep": "第 {{current}} / {{total}} 步",
|
||||
"taskProgressOpen": "查看任务进度",
|
||||
"taskProgressDetails": "任务进度详情",
|
||||
"taskProgressUnnamed": "未命名任务",
|
||||
"completedUnread": "已完成,尚未查看",
|
||||
"newConversationInProject": "在此项目中新建对话",
|
||||
"newUnassignedConversation": "新建无项目对话",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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, /<button[^>]+id="chat-return-latest"[\s\S]*?<\/button>\s*<div id="agent-plan-progress"/);
|
||||
});
|
||||
|
||||
test('计划详情只在真实鼠标移动或主动操作后展开', () => {
|
||||
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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 审查文字保留足够行高且不会裁切字形', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260813-6">
|
||||
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
|
||||
<link rel="stylesheet" href="/static/css/c2.css">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<script src="/static/js/router.js?v=20260813-2"></script>
|
||||
@@ -1180,6 +1181,7 @@
|
||||
<span class="chat-return-latest-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
<div id="agent-plan-progress" class="agent-plan-progress" hidden aria-live="polite"></div>
|
||||
<div id="chat-input-container" class="chat-input-container">
|
||||
<div class="chat-composer-context" aria-label="当前会话上下文">
|
||||
<div class="chat-input-leading">
|
||||
@@ -6820,7 +6822,8 @@
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js?v=20260813-6"></script>
|
||||
<script src="/static/js/monitor.js?v=20260813-9"></script>
|
||||
<script src="/static/js/chat.js?v=20260813-3"></script>
|
||||
<script src="/static/js/chat.js?v=20260813-4"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260813-3"></script>
|
||||
<script src="/static/js/hitl.js?v=20260811-4"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user