feat(ui): 显示 Agent 任务进度列表 (#251)

This commit is contained in:
RuoJi6
2026-08-14 10:44:03 +08:00
committed by GitHub
parent 7c45011074
commit a1615143be
18 changed files with 1131 additions and 6 deletions
+2
View File
@@ -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)
+125
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+14
View File
@@ -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
+79
View File
@@ -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)
}
}