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)
}
}
+203
View File
@@ -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;
}
}
+7 -1
View File
@@ -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;
+4
View File
@@ -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",
+4
View File
@@ -550,6 +550,10 @@
"conversationPreviewViewed": "已查看",
"conversationPreviewConversation": "对话",
"returnToLatest": "回到最新消息",
"taskProgressStep": "第 {{current}} / {{total}} 步",
"taskProgressOpen": "查看任务进度",
"taskProgressDetails": "任务进度详情",
"taskProgressUnnamed": "未命名任务",
"completedUnread": "已完成,尚未查看",
"newConversationInProject": "在此项目中新建对话",
"newUnassignedConversation": "新建无项目对话",
+7
View File
@@ -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/);
});
+316
View File
@@ -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);
+74
View File
@@ -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/);
});
+2 -2
View File
@@ -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/);
});
+15
View File
@@ -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 });
+2 -2
View File
@@ -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 审查文字保留足够行高且不会裁切字形', () => {
+18
View File
@@ -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);
+4 -1
View File
@@ -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>