mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 11:19:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c345880765 | ||
|
|
a1615143be | ||
|
|
7c45011074 | ||
|
|
eb6bab574f | ||
|
|
b170f2c4b1 | ||
|
|
02216a4660 | ||
|
|
a53e4a4a64 |
+2
-2
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.12"
|
||||
version: "v1.7.13"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -138,7 +138,7 @@ hitl:
|
||||
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||
retention_days: 90
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search, upsert_project_fact]
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search, upsert_project_fact, get_project_fact]
|
||||
# audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
|
||||
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1353,6 +1353,39 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateProcessDetailContent 更新流式聚合详情的正文与元数据。使用固定记录 ID,
|
||||
// 避免每个 token 新增一行,同时让页面刷新能读取到尚未结束的规划输出。
|
||||
func (db *DB) UpdateProcessDetailContent(id, message string, data interface{}) error {
|
||||
var dataJSON string
|
||||
if data != nil {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化过程详情数据失败: %w", err)
|
||||
}
|
||||
dataJSON = string(jsonData)
|
||||
}
|
||||
result, err := db.Exec(
|
||||
"UPDATE process_details SET message = ?, data = ? WHERE id = ?",
|
||||
message, dataJSON, strings.TrimSpace(id),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新过程详情失败: %w", err)
|
||||
}
|
||||
if affected, affectedErr := result.RowsAffected(); affectedErr == nil && affected == 0 {
|
||||
return fmt.Errorf("过程详情不存在: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProcessDetail 删除被判定为工具结果回显的临时规划记录。
|
||||
func (db *DB) DeleteProcessDetail(id string) error {
|
||||
_, err := db.Exec("DELETE FROM process_details WHERE id = ?", strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除过程详情失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProcessDetails 获取消息的过程详情
|
||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||
rows, err := db.Query(
|
||||
@@ -1420,6 +1453,10 @@ type ProcessDetailsSummary struct {
|
||||
ToolCount int `json:"toolCount"`
|
||||
ToolExecutions []ProcessDetailsToolExecution `json:"toolExecutions,omitempty"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessDetailsToolExecution struct {
|
||||
@@ -1442,6 +1479,54 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
}
|
||||
|
||||
summary := &ProcessDetailsSummary{Total: total}
|
||||
var messageCreatedAt, messageUpdatedAt sql.NullString
|
||||
var messageContent string
|
||||
if err := db.QueryRow(
|
||||
"SELECT created_at, updated_at, content FROM messages WHERE id = ?",
|
||||
messageID,
|
||||
).Scan(&messageCreatedAt, &messageUpdatedAt, &messageContent); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("查询过程详情耗时失败: %w", err)
|
||||
}
|
||||
if messageCreatedAt.Valid {
|
||||
if startedAt := parseDBTime(messageCreatedAt.String); !startedAt.IsZero() {
|
||||
summary.StartedAt = &startedAt
|
||||
}
|
||||
}
|
||||
var terminalEvent, terminalCreatedAt string
|
||||
terminalErr := db.QueryRow(`
|
||||
SELECT event_type, created_at
|
||||
FROM process_details
|
||||
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
if terminalErr != nil && !errors.Is(terminalErr, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("查询过程详情终态失败: %w", terminalErr)
|
||||
}
|
||||
if terminalEvent != "" {
|
||||
switch terminalEvent {
|
||||
case "cancelled":
|
||||
summary.Status = "cancelled"
|
||||
case "timeout":
|
||||
summary.Status = "timeout"
|
||||
default:
|
||||
summary.Status = "failed"
|
||||
}
|
||||
if completedAt := parseDBTime(terminalCreatedAt); !completedAt.IsZero() {
|
||||
summary.CompletedAt = &completedAt
|
||||
}
|
||||
} else if strings.TrimSpace(messageContent) == "处理中..." || strings.TrimSpace(messageContent) == "Processing..." {
|
||||
summary.Status = "running"
|
||||
} else {
|
||||
summary.Status = "completed"
|
||||
if messageUpdatedAt.Valid {
|
||||
if completedAt := parseDBTime(messageUpdatedAt.String); !completedAt.IsZero() {
|
||||
summary.CompletedAt = &completedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
if summary.StartedAt != nil && summary.CompletedAt != nil && !summary.CompletedAt.Before(*summary.StartedAt) {
|
||||
summary.DurationMs = summary.CompletedAt.Sub(*summary.StartedAt).Milliseconds()
|
||||
}
|
||||
if total == 0 {
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -105,6 +106,63 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
completedAt := "2026-08-10T08:12:59Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"done", startedAt, completedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update message timing: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "completed" {
|
||||
t.Fatalf("status = %q, want completed", summary.Status)
|
||||
}
|
||||
if summary.StartedAt == nil || summary.CompletedAt == nil {
|
||||
t.Fatalf("timing missing: %#v", summary)
|
||||
}
|
||||
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"处理中...", startedAt, startedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update running placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
|
||||
messageID, conversationID); err != nil {
|
||||
t.Fatalf("insert cancelled detail: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "cancelled" {
|
||||
t.Fatalf("status = %q, want cancelled", summary.Status)
|
||||
}
|
||||
if summary.CompletedAt == nil {
|
||||
t.Fatal("cancelled summary should expose a fixed completion time")
|
||||
}
|
||||
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
||||
|
||||
+70
-23
@@ -75,8 +75,11 @@ found:
|
||||
|
||||
// responsePlanAgg buffers main-assistant response_stream chunks for one "planning" process_detail row.
|
||||
type responsePlanAgg struct {
|
||||
meta map[string]interface{}
|
||||
b strings.Builder
|
||||
meta map[string]interface{}
|
||||
b strings.Builder
|
||||
detailID string
|
||||
lastPersistAt time.Time
|
||||
lastPersistSize int
|
||||
}
|
||||
|
||||
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
||||
@@ -145,30 +148,36 @@ func responseStreamIterationFromMeta(m map[string]interface{}) int {
|
||||
}
|
||||
}
|
||||
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) string {
|
||||
if respPlan == nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
plan := normalizeProcessDetailText(respPlan.b.String())
|
||||
if plan == "" {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
dataMap, ok := toolData.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
res, ok := dataMap["result"].(string)
|
||||
if !ok {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
r := normalizeProcessDetailText(res)
|
||||
if r == "" {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if plan == r || strings.HasSuffix(plan, r) {
|
||||
detailID := respPlan.detailID
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
respPlan.detailID = ""
|
||||
respPlan.lastPersistAt = time.Time{}
|
||||
respPlan.lastPersistSize = 0
|
||||
return detailID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AgentHandler Agent处理器
|
||||
@@ -221,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
|
||||
@@ -976,14 +999,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
syncHitlCognition := func() {
|
||||
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
||||
}
|
||||
flushResponsePlan := func() {
|
||||
persistResponsePlan := func(reset bool) {
|
||||
if assistantMessageID == "" {
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(respPlan.b.String())
|
||||
if content == "" {
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
if reset {
|
||||
respPlan = responsePlanAgg{}
|
||||
}
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
@@ -992,13 +1016,26 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
for k, v := range respPlan.meta {
|
||||
data[k] = v
|
||||
}
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
||||
var err error
|
||||
if respPlan.detailID == "" {
|
||||
respPlan.detailID, err = h.db.AddProcessDetailWithID(
|
||||
assistantMessageID, conversationID, "planning", content, data,
|
||||
)
|
||||
} else {
|
||||
err = h.db.UpdateProcessDetailContent(respPlan.detailID, content, data)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||
} else {
|
||||
respPlan.lastPersistAt = time.Now()
|
||||
respPlan.lastPersistSize = respPlan.b.Len()
|
||||
}
|
||||
syncHitlCognition()
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
if reset {
|
||||
respPlan = responsePlanAgg{}
|
||||
}
|
||||
}
|
||||
flushResponsePlan := func() { persistResponsePlan(true) }
|
||||
|
||||
flushThinkingStreams := func() {
|
||||
if assistantMessageID == "" {
|
||||
@@ -1068,15 +1105,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
|
||||
deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result"
|
||||
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅。
|
||||
// 工具事件需先落库拿 processDetailId,再向前端发送摘要,避免大 payload 默认进入浏览器。
|
||||
// 主 HTTP SSE 与 taskEventBus 必须同时写入:页面刷新会切断原连接,刷新后的
|
||||
// GET task-events 订阅依赖 eventBus 才能继续收到后续迭代。机器人等无主 SSE
|
||||
// 的来源同样只写 eventBus。工具事件需先落库拿 processDetailId,再发送摘要。
|
||||
if !deferToolProgressSend {
|
||||
clientData := enrichProgressEventData(data, conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
|
||||
// 保存tool_call事件中的参数
|
||||
@@ -1329,6 +1366,14 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
respPlan.meta[k] = v
|
||||
}
|
||||
}
|
||||
// 运行中的主回复不能只保存在内存:刷新会销毁旧页面,新的 task-events
|
||||
// 订阅只能收到未来增量。按时间或增量大小节流更新同一条 planning 记录,
|
||||
// 这样刷新时能从数据库恢复刷新前已经展示的全部文本。
|
||||
if respPlan.lastPersistAt.IsZero() ||
|
||||
time.Since(respPlan.lastPersistAt) >= 300*time.Millisecond ||
|
||||
respPlan.b.Len()-respPlan.lastPersistSize >= 1024 {
|
||||
persistResponsePlan(false)
|
||||
}
|
||||
syncHitlCognition()
|
||||
return
|
||||
}
|
||||
@@ -1439,7 +1484,11 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
eventType != "eino_agent_reply_stream_delta" &&
|
||||
eventType != "eino_agent_reply_stream_end" {
|
||||
if eventType == "tool_result" {
|
||||
discardPlanningIfEchoesToolResult(&respPlan, data)
|
||||
if detailID := discardPlanningIfEchoesToolResult(&respPlan, data); detailID != "" {
|
||||
if err := h.db.DeleteProcessDetail(detailID); err != nil {
|
||||
h.logger.Warn("删除工具结果回显规划失败", zap.Error(err), zap.String("processDetailId", detailID))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
|
||||
flushResponsePlan()
|
||||
@@ -1455,17 +1504,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
} else if deferToolProgressSend {
|
||||
clientData := enrichProgressEventData(summarizeProcessDetailData(eventType, data), conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
@@ -51,6 +53,77 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_MirrorsWebStreamEvents 页面刷新后 task-events 订阅必须
|
||||
// 继续收到原 Web SSE 任务的后续事件,不能只等数据库最终结果。
|
||||
func TestCreateProgressCallback_MirrorsWebStreamEvents(t *testing.T) {
|
||||
bus := NewTaskEventBus()
|
||||
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{}, taskEventBus: bus}
|
||||
_, events := bus.Subscribe("conv-refresh-stream")
|
||||
primaryCalls := 0
|
||||
cb := h.createProgressCallback(
|
||||
context.Background(), nil, "conv-refresh-stream", "",
|
||||
func(eventType, message string, data interface{}) { primaryCalls++ },
|
||||
)
|
||||
|
||||
cb("progress", "第 3 轮", map[string]interface{}{"iteration": 3})
|
||||
if primaryCalls != 1 {
|
||||
t.Fatalf("expected primary SSE callback once, got %d", primaryCalls)
|
||||
}
|
||||
select {
|
||||
case payload := <-events:
|
||||
body := string(payload)
|
||||
if !strings.Contains(body, `"type":"progress"`) || !strings.Contains(body, `"conversationId":"conv-refresh-stream"`) {
|
||||
t.Fatalf("unexpected mirrored event: %s", body)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected progress event mirrored to task event bus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProgressCallback_PersistsRunningResponseBeforeDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
conv, err := db.CreateConversation("refresh-running", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
|
||||
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||
meta := map[string]interface{}{
|
||||
"streamId": "response-refresh-1",
|
||||
"einoAgent": "cyberstrike-eino-single",
|
||||
"orchestration": "eino_single",
|
||||
}
|
||||
cb("response_start", "", meta)
|
||||
cb("response_delta", "刷新前已生成的第一部分", openai.WithSSEAccumulated(meta, "刷新前已生成的第一部分"))
|
||||
|
||||
details, err := db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails: %v", err)
|
||||
}
|
||||
if len(details) != 1 || details[0].EventType != "planning" || details[0].Message != "刷新前已生成的第一部分" {
|
||||
t.Fatalf("expected one running planning snapshot, got %+v", details)
|
||||
}
|
||||
|
||||
longer := "刷新前已生成的第一部分" + strings.Repeat("继续迭代", 300)
|
||||
cb("response_delta", "继续迭代", openai.WithSSEAccumulated(meta, longer))
|
||||
details, err = db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails after update: %v", err)
|
||||
}
|
||||
if len(details) != 1 || details[0].Message != longer {
|
||||
t.Fatalf("running snapshot should update in-place, rows=%d len=%d", len(details), len(details[0].Message))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -118,8 +119,7 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
}
|
||||
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
|
||||
title := safeTruncateString(task.Message, 50)
|
||||
batchMeta := audit.ConversationCreateMeta("batch_task")
|
||||
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
|
||||
batchMeta := batchSubTaskConversationMeta(h.config, queue)
|
||||
conv, err := h.db.CreateConversation(title, batchMeta)
|
||||
if err != nil {
|
||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
@@ -321,6 +321,17 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||
}
|
||||
|
||||
func batchSubTaskConversationMeta(cfg *config.Config, queue *BatchTaskQueue) database.ConversationCreateMeta {
|
||||
meta := audit.ConversationCreateMeta("batch_task")
|
||||
if queue == nil {
|
||||
meta.ProjectID = effectiveProjectID(cfg, "")
|
||||
return meta
|
||||
}
|
||||
meta.ProjectID = effectiveProjectID(cfg, queue.ProjectID)
|
||||
meta.RoleName = strings.TrimSpace(queue.Role)
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *AgentHandler) handleBatchSubTaskRunError(
|
||||
queueID string,
|
||||
task *BatchTask,
|
||||
|
||||
@@ -61,6 +61,18 @@ func TestBatchQueueExecutionShouldStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSubTaskConversationMetaKeepsQueueRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
meta := batchSubTaskConversationMeta(nil, &BatchTaskQueue{Role: " 渗透测试 "})
|
||||
if meta.Source != "batch_task" {
|
||||
t.Fatalf("expected batch_task source, got %q", meta.Source)
|
||||
}
|
||||
if meta.RoleName != "渗透测试" {
|
||||
t.Fatalf("expected queue role to be stored on child conversation, got %q", meta.RoleName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteQueueBlockedWhileExecutorActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := NewBatchTaskManager(zap.NewNop())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+280
-57
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
tool_call_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
reviewer TEXT NOT NULL DEFAULT 'human',
|
||||
decision TEXT,
|
||||
decision_comment TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
@@ -98,15 +99,179 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
||||
// On startup, cancel all orphaned pending interrupts from previous process.
|
||||
// Their in-memory channels are gone, so they can never be resolved.
|
||||
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
||||
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP WHERE status='pending'`)
|
||||
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP, decided_by='system'
|
||||
WHERE status='pending'`)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
|
||||
}
|
||||
if err := m.reconcileRestartInterruptedMessages(); err != nil {
|
||||
m.logger.Warn("failed to finalize assistant messages interrupted by process restart", zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileRestartInterruptedMessages completes durable terminal state for
|
||||
// historical assistant placeholders that have explicit evidence of being over:
|
||||
// a terminal HITL/process event, or a later message in the same conversation.
|
||||
// The evidence requirement avoids rewriting a placeholder that could still be
|
||||
// recoverable by another runtime.
|
||||
func (m *HITLManager) reconcileRestartInterruptedMessages() error {
|
||||
rows, err := m.db.Query(`
|
||||
SELECT msg.id, msg.conversation_id,
|
||||
COALESCE((
|
||||
SELECT pd.event_type
|
||||
FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||
ORDER BY pd.created_at DESC LIMIT 1
|
||||
), '') AS terminal_event,
|
||||
COALESCE((
|
||||
SELECT hi.status
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS hitl_status,
|
||||
COALESCE((
|
||||
SELECT hi.decision
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS hitl_decision,
|
||||
COALESCE((
|
||||
SELECT hi.decision_comment
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS decision_comment,
|
||||
COALESCE((
|
||||
SELECT MAX(COALESCE(hi.decided_at, hi.created_at))
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
), (
|
||||
SELECT MIN(later.created_at)
|
||||
FROM messages later
|
||||
WHERE later.conversation_id = msg.conversation_id
|
||||
AND later.created_at > msg.created_at
|
||||
), (
|
||||
SELECT MAX(pd.created_at)
|
||||
FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
), msg.updated_at, msg.created_at) AS interrupted_at
|
||||
FROM messages msg
|
||||
WHERE msg.role = 'assistant'
|
||||
AND TRIM(msg.content) IN ('处理中...', 'Processing...')
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
AND (hi.status IN ('cancelled', 'timeout')
|
||||
OR (hi.status = 'decided' AND hi.decision = 'reject'))
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM messages later
|
||||
WHERE later.conversation_id = msg.conversation_id
|
||||
AND later.created_at > msg.created_at
|
||||
)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type interruptedMessage struct {
|
||||
messageID string
|
||||
conversationID string
|
||||
terminalEvent string
|
||||
hitlStatus string
|
||||
hitlDecision string
|
||||
decisionComment string
|
||||
interruptedAt string
|
||||
}
|
||||
var interrupted []interruptedMessage
|
||||
for rows.Next() {
|
||||
var item interruptedMessage
|
||||
if err := rows.Scan(&item.messageID, &item.conversationID, &item.terminalEvent,
|
||||
&item.hitlStatus, &item.hitlDecision, &item.decisionComment, &item.interruptedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
interrupted = append(interrupted, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(interrupted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := m.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, item := range interrupted {
|
||||
eventType := strings.ToLower(strings.TrimSpace(item.terminalEvent))
|
||||
decision := strings.ToLower(strings.TrimSpace(item.hitlDecision))
|
||||
comment := strings.ToLower(strings.TrimSpace(item.decisionComment))
|
||||
if eventType == "" {
|
||||
if strings.EqualFold(strings.TrimSpace(item.hitlStatus), "timeout") || strings.Contains(comment, "timeout") {
|
||||
eventType = "timeout"
|
||||
} else {
|
||||
eventType = "cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
notice := "任务因服务重启已中断。"
|
||||
reason := "process_restarted"
|
||||
switch eventType {
|
||||
case "timeout":
|
||||
notice = "任务等待审批超时,已自动拒绝。"
|
||||
reason = "hitl_timeout"
|
||||
case "error":
|
||||
notice = "任务执行失败,已停止。"
|
||||
reason = "execution_error"
|
||||
case "cancelled":
|
||||
if decision == "reject" && comment != "process restarted" {
|
||||
notice = "任务审批已拒绝,执行已停止。"
|
||||
reason = "hitl_rejected"
|
||||
} else if comment == "process restarted" {
|
||||
notice = "任务因服务重启已中断,审批已取消。"
|
||||
}
|
||||
default:
|
||||
eventType = "cancelled"
|
||||
}
|
||||
detailData, _ := json.Marshal(map[string]string{"reason": reason, "status": eventType})
|
||||
result, err := tx.Exec(`
|
||||
UPDATE messages
|
||||
SET content = ?, updated_at = ?
|
||||
WHERE id = ? AND TRIM(content) IN ('处理中...', 'Processing...')`,
|
||||
notice, item.interruptedAt, item.messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, _ := result.RowsAffected()
|
||||
if updated == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM process_details
|
||||
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||
)`, uuid.NewString(), item.messageID, item.conversationID, eventType, notice, string(detailData),
|
||||
item.interruptedAt, item.messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeHitlMode(mode string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(mode))
|
||||
if v == "" {
|
||||
@@ -234,13 +399,14 @@ func (m *HITLManager) NeedsToolApproval(conversationID, toolName string) bool {
|
||||
return need
|
||||
}
|
||||
|
||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
|
||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer string) (*pendingInterrupt, error) {
|
||||
now := time.Now()
|
||||
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
reviewer = normalizeHitlReviewer(reviewer)
|
||||
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, now); err != nil {
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, reviewer, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
|
||||
@@ -253,9 +419,12 @@ func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID,
|
||||
ToolCallID: toolCallID,
|
||||
decideCh: make(chan hitlDecision, 1),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pending[id] = p
|
||||
m.mu.Unlock()
|
||||
// Agent 审查不会等待人工决策,也不应进入人工审批的内存待办队列。
|
||||
if reviewer != "audit_agent" {
|
||||
m.mu.Lock()
|
||||
m.pending[id] = p
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -471,66 +640,107 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
return nil, nil
|
||||
}
|
||||
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
||||
approvalStartedAt := time.Now().UTC()
|
||||
timeoutSeconds := int(cfg.Timeout / time.Second)
|
||||
var approvalExpiresAt *time.Time
|
||||
if timeoutSeconds > 0 {
|
||||
expiresAt := approvalStartedAt.Add(cfg.Timeout)
|
||||
approvalExpiresAt = &expiresAt
|
||||
}
|
||||
payload["hitlApproval"] = map[string]interface{}{
|
||||
"createdAt": approvalStartedAt,
|
||||
"timeoutSeconds": timeoutSeconds,
|
||||
"expiresAt": approvalExpiresAt,
|
||||
}
|
||||
payloadRaw, _ := json.Marshal(payload)
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer)
|
||||
if err != nil {
|
||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
emitHITL := func(eventType, message string, eventData map[string]interface{}) {
|
||||
clientData := enrichProgressEventData(eventData, conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
}
|
||||
if strings.TrimSpace(assistantMessageID) != "" && h.db != nil {
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, clientData); err != nil {
|
||||
h.logger.Warn("保存 HITL 过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Reviewer == "audit_agent" {
|
||||
emitHITL("hitl_audit_agent_started", "审计 Agent 正在审查此请求", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"reviewer": "audit_agent",
|
||||
"status": "audit_running",
|
||||
"payload": payload,
|
||||
})
|
||||
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
||||
now := time.Now()
|
||||
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
||||
ad.Decision, ad.Comment, now, p.InterruptID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||
emitHITL("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"status": "decided",
|
||||
"decision": ad.Decision,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
if ad.Decision == "reject" {
|
||||
emitHITL("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": ad.Decision,
|
||||
"decision": "reject",
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
}
|
||||
if ad.Decision == "reject" {
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
return &ad, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
emitHITL("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": "approve",
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &ad, nil
|
||||
}
|
||||
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"mode": cfg.Mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
emitHITL("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"mode": cfg.Mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"reviewer": "human",
|
||||
"status": "pending",
|
||||
"createdAt": approvalStartedAt,
|
||||
"timeoutSeconds": timeoutSeconds,
|
||||
"expiresAt": approvalExpiresAt,
|
||||
"payload": payload,
|
||||
})
|
||||
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
|
||||
if waitErr != nil {
|
||||
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
|
||||
@@ -550,28 +760,41 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
|
||||
timedOut := strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout")
|
||||
if timedOut {
|
||||
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": d.Comment,
|
||||
})
|
||||
status := "decided"
|
||||
decidedBy := "human"
|
||||
if timedOut {
|
||||
status = "timeout"
|
||||
decidedBy = "system"
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||
emitHITL("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"status": status,
|
||||
"decision": "reject",
|
||||
"comment": d.Comment,
|
||||
"editedArgs": d.EditedArguments,
|
||||
"decidedBy": decidedBy,
|
||||
"reviewer": "human",
|
||||
})
|
||||
return &d, nil
|
||||
}
|
||||
emitHITL("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": "approve",
|
||||
"comment": d.Comment,
|
||||
"editedArgs": d.EditedArguments,
|
||||
"reviewer": "human",
|
||||
})
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
@@ -39,11 +39,14 @@ func normalizeHitlDecidedBy(v string) string {
|
||||
|
||||
func (m *HITLManager) migrateHitlSchemaColumns() {
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET reviewer='audit_agent'
|
||||
WHERE COALESCE(decided_by, '') IN ('audit_agent', 'agent', 'ai')`)
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||
}
|
||||
|
||||
func hitlInterruptRowToMap(
|
||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
|
||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string,
|
||||
messageID sql.NullString,
|
||||
decision, comment sql.NullString,
|
||||
createdAt time.Time,
|
||||
@@ -62,6 +65,7 @@ func hitlInterruptRowToMap(
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
"status": rowStatus,
|
||||
"reviewer": reviewer,
|
||||
"decision": decision.String,
|
||||
"comment": comment.String,
|
||||
"decidedBy": decidedBy,
|
||||
@@ -77,7 +81,7 @@ func hitlInterruptRowToMap(
|
||||
|
||||
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
||||
where, args := h.buildHitlLogsWhere(logs)
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||
return q, args
|
||||
}
|
||||
|
||||
@@ -87,7 +91,9 @@ func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
|
||||
if logs {
|
||||
q += " AND status != 'pending'"
|
||||
} else {
|
||||
q += " AND status = 'pending'"
|
||||
// 该接口只返回真正等待用户操作的人工审批。Agent 审查即使正在运行,
|
||||
// 也不应触发弹窗、倒计时或项目待审批计数。
|
||||
q += " AND status = 'pending' AND COALESCE(reviewer,'human') = 'human'"
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
@@ -131,15 +137,15 @@ func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gi
|
||||
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -252,13 +258,13 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -271,7 +277,7 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
|
||||
func (h *AgentHandler) filterAllowedHitlInterruptIDs(c *gin.Context, ids []string) ([]string, error) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEnsureSchemaCancelsPendingInterruptsAfterRestart(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-restart.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("restart interrupted", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message, err := db.AddMessage(conversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create assistant placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
|
||||
"restart-pending", conversation.ID, message.ID, "approval", "browser", "tool-call-1", `{}`); err != nil {
|
||||
t.Fatalf("insert pending interrupt: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("reconcile restart: %v", err)
|
||||
}
|
||||
|
||||
var status, decision, comment, decidedBy string
|
||||
var decidedAt sql.NullTime
|
||||
if err := db.QueryRow(`SELECT status, decision, decision_comment, decided_by, decided_at
|
||||
FROM hitl_interrupts WHERE id = ?`, "restart-pending").
|
||||
Scan(&status, &decision, &comment, &decidedBy, &decidedAt); err != nil {
|
||||
t.Fatalf("query reconciled interrupt: %v", err)
|
||||
}
|
||||
if status != "cancelled" || decision != "reject" || comment != "process restarted" {
|
||||
t.Fatalf("unexpected restart decision: status=%q decision=%q comment=%q", status, decision, comment)
|
||||
}
|
||||
if decidedBy != "system" {
|
||||
t.Fatalf("decided_by=%q, want system", decidedBy)
|
||||
}
|
||||
if !decidedAt.Valid {
|
||||
t.Fatal("decided_at should be set after restart reconciliation")
|
||||
}
|
||||
|
||||
var content string
|
||||
var updatedAt sql.NullTime
|
||||
if err := db.QueryRow(`SELECT content, updated_at FROM messages WHERE id = ?`, message.ID).
|
||||
Scan(&content, &updatedAt); err != nil {
|
||||
t.Fatalf("query reconciled assistant message: %v", err)
|
||||
}
|
||||
if content != "任务因服务重启已中断,审批已取消。" {
|
||||
t.Fatalf("assistant content=%q, want restart interruption notice", content)
|
||||
}
|
||||
if !updatedAt.Valid {
|
||||
t.Fatal("assistant updated_at should be set to the interruption time")
|
||||
}
|
||||
var eventType, eventMessage string
|
||||
if err := db.QueryRow(`SELECT event_type, message FROM process_details WHERE message_id = ?`, message.ID).
|
||||
Scan(&eventType, &eventMessage); err != nil {
|
||||
t.Fatalf("query restart cancellation process detail: %v", err)
|
||||
}
|
||||
if eventType != "cancelled" || eventMessage != content {
|
||||
t.Fatalf("unexpected terminal detail: type=%q message=%q", eventType, eventMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSchemaFinalizesOnlyHistoricalPlaceholdersWithTerminalEvidence(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-history.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
|
||||
supersededConversation, err := db.CreateConversation("superseded placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create superseded conversation: %v", err)
|
||||
}
|
||||
superseded, err := db.AddMessage(supersededConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create superseded placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.AddMessage(supersededConversation.ID, "user", "继续", nil); err != nil {
|
||||
t.Fatalf("create later message: %v", err)
|
||||
}
|
||||
|
||||
timeoutConversation, err := db.CreateConversation("timeout placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create timeout conversation: %v", err)
|
||||
}
|
||||
timedOut, err := db.AddMessage(timeoutConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create timeout placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||
VALUES (?, ?, ?, 'approval', 'browser', 'timeout', 'reject', 'HITL timeout auto-reject for safety', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
"timeout-interrupt", timeoutConversation.ID, timedOut.ID); err != nil {
|
||||
t.Fatalf("insert timeout interrupt: %v", err)
|
||||
}
|
||||
|
||||
rejectedConversation, err := db.CreateConversation("rejected placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create rejected conversation: %v", err)
|
||||
}
|
||||
rejected, err := db.AddMessage(rejectedConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create rejected placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||
VALUES (?, ?, ?, 'approval', 'exec', 'decided', 'reject', 'user rejected', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
"rejected-interrupt", rejectedConversation.ID, rejected.ID); err != nil {
|
||||
t.Fatalf("insert rejected interrupt: %v", err)
|
||||
}
|
||||
|
||||
activeConversation, err := db.CreateConversation("potentially active placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create active conversation: %v", err)
|
||||
}
|
||||
potentiallyActive, err := db.AddMessage(activeConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create potentially active placeholder: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("reconcile historical placeholders: %v", err)
|
||||
}
|
||||
|
||||
assertTerminal := func(messageID, wantContent, wantEvent string) {
|
||||
t.Helper()
|
||||
var content, eventType string
|
||||
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, messageID).Scan(&content); err != nil {
|
||||
t.Fatalf("query message %s: %v", messageID, err)
|
||||
}
|
||||
if content != wantContent {
|
||||
t.Fatalf("message %s content=%q, want %q", messageID, content, wantContent)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT event_type FROM process_details WHERE message_id = ?
|
||||
AND event_type IN ('cancelled', 'timeout', 'error')`, messageID).Scan(&eventType); err != nil {
|
||||
t.Fatalf("query terminal detail %s: %v", messageID, err)
|
||||
}
|
||||
if eventType != wantEvent {
|
||||
t.Fatalf("message %s event=%q, want %q", messageID, eventType, wantEvent)
|
||||
}
|
||||
}
|
||||
assertTerminal(superseded.ID, "任务因服务重启已中断。", "cancelled")
|
||||
assertTerminal(timedOut.ID, "任务等待审批超时,已自动拒绝。", "timeout")
|
||||
assertTerminal(rejected.ID, "任务审批已拒绝,执行已停止。", "cancelled")
|
||||
|
||||
var activeContent string
|
||||
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, potentiallyActive.ID).Scan(&activeContent); err != nil {
|
||||
t.Fatalf("query potentially active message: %v", err)
|
||||
}
|
||||
if activeContent != "处理中..." {
|
||||
t.Fatalf("potentially active message was rewritten to %q", activeContent)
|
||||
}
|
||||
var terminalCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM process_details WHERE message_id = ?
|
||||
AND event_type IN ('cancelled', 'timeout', 'error')`, potentiallyActive.ID).Scan(&terminalCount); err != nil {
|
||||
t.Fatalf("count active terminal details: %v", err)
|
||||
}
|
||||
if terminalCount != 0 {
|
||||
t.Fatalf("potentially active message got %d terminal details", terminalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditAgentInterruptIsNotHumanPendingWork(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-reviewer.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
|
||||
audit, err := manager.CreatePendingInterrupt("conversation-audit", "message-audit", "review_edit", "exec", "call-audit", `{}`, "audit_agent")
|
||||
if err != nil {
|
||||
t.Fatalf("create audit interrupt: %v", err)
|
||||
}
|
||||
human, err := manager.CreatePendingInterrupt("conversation-human", "message-human", "approval", "exec", "call-human", `{}`, "human")
|
||||
if err != nil {
|
||||
t.Fatalf("create human interrupt: %v", err)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
_, auditWaitsForHuman := manager.pending[audit.InterruptID]
|
||||
_, humanWaitsForHuman := manager.pending[human.InterruptID]
|
||||
manager.mu.RUnlock()
|
||||
if auditWaitsForHuman {
|
||||
t.Fatal("audit-agent interrupt must not enter the human pending queue")
|
||||
}
|
||||
if !humanWaitsForHuman {
|
||||
t.Fatal("human interrupt should enter the human pending queue")
|
||||
}
|
||||
|
||||
query, args := (&AgentHandler{}).buildHitlListQuery(false)
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("unexpected pending query args: %v", args)
|
||||
}
|
||||
if !strings.Contains(query, "COALESCE(reviewer,'human') = 'human'") {
|
||||
t.Fatalf("pending query must filter out audit-agent work: %s", query)
|
||||
}
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("query human pending interrupts: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := (&AgentHandler{}).scanHitlInterruptRows(rows)
|
||||
if err != nil {
|
||||
t.Fatalf("scan human pending interrupts: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0]["id"] != human.InterruptID || items[0]["reviewer"] != "human" {
|
||||
t.Fatalf("unexpected human pending result: %#v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHITLBuiltInWhitelistExemptsWriteFile(t *testing.T) {
|
||||
h := &AgentHandler{}
|
||||
req := h.hitlRequestWithMergedConfigWhitelist(&HITLRequest{
|
||||
Enabled: true,
|
||||
Mode: "approval",
|
||||
})
|
||||
|
||||
manager := NewHITLManager(nil, nil)
|
||||
manager.ActivateConversation("conversation-1", req)
|
||||
|
||||
if manager.NeedsToolApproval("conversation-1", "write_file") {
|
||||
t.Fatal("write_file should use the built-in HITL exemption")
|
||||
}
|
||||
if !manager.NeedsToolApproval("conversation-1", "exec") {
|
||||
t.Fatal("non-exempt tools should still require approval")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
@@ -19,8 +18,8 @@ import (
|
||||
|
||||
// PlanExecuteRootArgs 构建 Eino adk/prebuilt/planexecute 根 Agent 所需参数。
|
||||
type PlanExecuteRootArgs struct {
|
||||
MainToolCallingModel *openai.ChatModel
|
||||
ExecModel *openai.ChatModel
|
||||
MainToolCallingModel model.ToolCallingChatModel
|
||||
ExecModel model.ToolCallingChatModel
|
||||
OrchInstruction string
|
||||
ToolsCfg adk.ToolsConfig
|
||||
ExecMaxIter int
|
||||
|
||||
@@ -121,10 +121,11 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||
|
||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseMainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino single 模型: %w", err)
|
||||
}
|
||||
mainModel := newStreamToolCallIndexRepairModel(baseMainModel)
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// streamToolCallIndexRepairModel isolates an OpenAI-compatible streaming
|
||||
// protocol defect before Eino concatenates response chunks. Some providers
|
||||
// reuse a tool-call index for different non-empty tool-call IDs in one stream.
|
||||
// Eino correctly rejects that shape because one index represents one call.
|
||||
//
|
||||
// The wrapper keeps valid streams untouched. When it sees the conflicting
|
||||
// shape, it assigns each distinct ID a stable, stream-local index so Eino can
|
||||
// retain all calls instead of aborting the agent run.
|
||||
type streamToolCallIndexRepairModel struct {
|
||||
base model.ToolCallingChatModel
|
||||
}
|
||||
|
||||
func newStreamToolCallIndexRepairModel(base model.ToolCallingChatModel) model.ToolCallingChatModel {
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
return &streamToolCallIndexRepairModel{base: base}
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
|
||||
return m.base.Generate(ctx, input, opts...)
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
stream, err := m.base.Stream(ctx, input, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := newStreamToolCallIndexRepairState()
|
||||
return schema.StreamReaderWithConvert(stream, state.repairMessage), nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
withTools, err := m.base.WithTools(tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStreamToolCallIndexRepairModel(withTools), nil
|
||||
}
|
||||
|
||||
type streamToolCallIndexRepairState struct {
|
||||
indexByID map[string]int
|
||||
idByIndex map[int]string
|
||||
nextFreeIndex int
|
||||
}
|
||||
|
||||
func newStreamToolCallIndexRepairState() *streamToolCallIndexRepairState {
|
||||
return &streamToolCallIndexRepairState{
|
||||
indexByID: make(map[string]int),
|
||||
idByIndex: make(map[int]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamToolCallIndexRepairState) repairMessage(msg *schema.Message) (*schema.Message, error) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
var calls []schema.ToolCall
|
||||
changed := false
|
||||
for i := range msg.ToolCalls {
|
||||
call := msg.ToolCalls[i]
|
||||
if call.Index == nil || call.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceIndex := *call.Index
|
||||
if sourceIndex >= s.nextFreeIndex {
|
||||
s.nextFreeIndex = sourceIndex + 1
|
||||
}
|
||||
|
||||
assigned, known := s.indexByID[call.ID]
|
||||
if !known {
|
||||
assigned = sourceIndex
|
||||
if owner, occupied := s.idByIndex[assigned]; occupied && owner != call.ID {
|
||||
assigned = s.takeFreeIndex()
|
||||
}
|
||||
s.indexByID[call.ID] = assigned
|
||||
s.idByIndex[assigned] = call.ID
|
||||
}
|
||||
if assigned == sourceIndex {
|
||||
continue
|
||||
}
|
||||
if calls == nil {
|
||||
calls = append([]schema.ToolCall(nil), msg.ToolCalls...)
|
||||
}
|
||||
index := assigned
|
||||
calls[i].Index = &index
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return msg, nil
|
||||
}
|
||||
out := *msg
|
||||
out.ToolCalls = calls
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *streamToolCallIndexRepairState) takeFreeIndex() int {
|
||||
for {
|
||||
candidate := s.nextFreeIndex
|
||||
s.nextFreeIndex++
|
||||
if _, occupied := s.idByIndex[candidate]; !occupied {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type streamToolCallIndexFakeModel struct {
|
||||
chunks []*schema.Message
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
return schema.StreamReaderFromArray(m.chunks), nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairSeparatesConflictingIDs(t *testing.T) {
|
||||
index := 0
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "fc_call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "search", Arguments: `{"query":"one"}`},
|
||||
}}),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "fc_call_1", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "task", Arguments: `{"query":"two"}`},
|
||||
}}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
merged, err := schema.ConcatMessages(got)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcatMessages() error = %v", err)
|
||||
}
|
||||
if len(merged.ToolCalls) != 2 {
|
||||
t.Fatalf("tool call count = %d, want 2", len(merged.ToolCalls))
|
||||
}
|
||||
if merged.ToolCalls[0].ID != "fc_call_0" || merged.ToolCalls[1].ID != "fc_call_1" {
|
||||
t.Fatalf("tool call IDs = %#v", merged.ToolCalls)
|
||||
}
|
||||
if merged.ToolCalls[0].Index == nil || *merged.ToolCalls[0].Index != 0 || merged.ToolCalls[1].Index == nil || *merged.ToolCalls[1].Index != 1 {
|
||||
t.Fatalf("tool call indexes = %#v", merged.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairPreservesFragmentsForOneID(t *testing.T) {
|
||||
index := 0
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "search", Arguments: `{"query":"`},
|
||||
}}),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Arguments: `one"}`},
|
||||
}}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
merged, err := schema.ConcatMessages(got)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcatMessages() error = %v", err)
|
||||
}
|
||||
if len(merged.ToolCalls) != 1 || merged.ToolCalls[0].Function.Arguments != `{"query":"one"}` {
|
||||
t.Fatalf("tool calls = %#v", merged.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairLeavesValidParallelIndexesUntouched(t *testing.T) {
|
||||
first, second := 0, 1
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{
|
||||
{Index: &first, ID: "call_0", Type: "function", Function: schema.FunctionCall{Name: "search", Arguments: `{}`}},
|
||||
{Index: &second, ID: "call_1", Type: "function", Function: schema.FunctionCall{Name: "task", Arguments: `{}`}},
|
||||
}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
if len(got) != 1 || len(got[0].ToolCalls) != 2 {
|
||||
t.Fatalf("chunks = %#v", got)
|
||||
}
|
||||
if *got[0].ToolCalls[0].Index != 0 || *got[0].ToolCalls[1].Index != 1 {
|
||||
t.Fatalf("tool call indexes changed: %#v", got[0].ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func readStreamToolCallChunks(t *testing.T, chatModel model.ToolCallingChatModel) []*schema.Message {
|
||||
t.Helper()
|
||||
stream, err := chatModel.Stream(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream() error = %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var chunks []*schema.Message
|
||||
for {
|
||||
chunk, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
return chunks
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("Recv() error = %v", recvErr)
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
|
||||
const toolSearchToolName = "tool_search"
|
||||
|
||||
// HitlExemptMetaTools 为编排/元工具:不直接执行攻击动作,但会阻塞 agent 控制流。
|
||||
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel)。
|
||||
// HitlExemptMetaTools 为 HITL 内置免审批工具:包括编排/元工具,以及模型输出修复链路依赖的 write_file。
|
||||
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel);
|
||||
// write_file 必须免审批,否则长脚本或请求体无法先安全落盘,模型输出修复链路会被再次阻塞。
|
||||
var HitlExemptMetaTools = []string{
|
||||
toolSearchToolName,
|
||||
"skill",
|
||||
"task",
|
||||
"write_todos",
|
||||
"write_file",
|
||||
"transfer_to_agent",
|
||||
"exit",
|
||||
"TaskCreate",
|
||||
@@ -22,6 +24,7 @@ var HitlExemptMetaTools = []string{
|
||||
"TaskUpdate",
|
||||
"TaskList",
|
||||
"upsert_project_fact",
|
||||
"get_project_fact",
|
||||
}
|
||||
|
||||
// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool.
|
||||
|
||||
@@ -33,26 +33,32 @@ func TestHitlRejectToolResult_otherToolKeepsLegacyText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) {
|
||||
func TestMergeHitlExemptMetaTools_includesBuiltInExemptTools(t *testing.T) {
|
||||
merged := MergeHitlExemptMetaTools([]string{"read_file"})
|
||||
found := false
|
||||
foundToolSearch := false
|
||||
for _, name := range merged {
|
||||
if IsToolSearchTool(name) {
|
||||
found = true
|
||||
foundToolSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if !foundToolSearch {
|
||||
t.Fatalf("tool_search missing from %v", merged)
|
||||
}
|
||||
foundProjectFact := false
|
||||
foundBuiltInTools := map[string]bool{
|
||||
"write_file": false,
|
||||
"upsert_project_fact": false,
|
||||
"get_project_fact": false,
|
||||
}
|
||||
for _, name := range merged {
|
||||
if strings.EqualFold(strings.TrimSpace(name), "upsert_project_fact") {
|
||||
foundProjectFact = true
|
||||
break
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
if _, ok := foundBuiltInTools[normalized]; ok {
|
||||
foundBuiltInTools[normalized] = true
|
||||
}
|
||||
}
|
||||
if !foundProjectFact {
|
||||
t.Fatalf("upsert_project_fact missing from %v", merged)
|
||||
for name, found := range foundBuiltInTools {
|
||||
if !found {
|
||||
t.Fatalf("%s missing from %v", name, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,10 +218,11 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
|
||||
subModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseSubModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q ChatModel: %w", id, err)
|
||||
}
|
||||
subModel := newStreamToolCallIndexRepairModel(baseSubModel)
|
||||
|
||||
subDefs := ag.ToolsForRole(roleTools)
|
||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, nil, toolInvokeNotify, id)
|
||||
@@ -308,10 +309,11 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
|
||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseMainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("多代理主模型: %w", err)
|
||||
}
|
||||
mainModel := newStreamToolCallIndexRepairModel(baseMainModel)
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
@@ -481,19 +483,21 @@ func RunDeepAgent(
|
||||
MaxCompletionTokens: &maxCompletionTokens,
|
||||
}
|
||||
reasoning.ApplyPlanExecutePlannerModelConfig(plannerModelCfg, &appCfg.OpenAI)
|
||||
peMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||
basePEMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 规划模型: %w", perr)
|
||||
}
|
||||
peMainModel := newStreamToolCallIndexRepairModel(basePEMainModel)
|
||||
if logger != nil {
|
||||
logger.Info("plan_execute: planner/replanner 使用无 reasoning 的独立 ChatModel(ToolChoiceForced 兼容)",
|
||||
zap.String("model", appCfg.OpenAI.Model),
|
||||
)
|
||||
}
|
||||
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseExecModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
||||
}
|
||||
execModel := newStreamToolCallIndexRepairModel(baseExecModel)
|
||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||
var peFsMw adk.ChatModelAgentMiddleware
|
||||
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||
|
||||
+25
-15
@@ -2001,7 +2001,7 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tr {
|
||||
@@ -2010,24 +2010,29 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody:hover {
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-track {
|
||||
background: var(--c2-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
background-clip: content-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-thumb:hover {
|
||||
.c2-tasks-table tbody:hover::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--c2-text-muted) 78%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.c2-tasks-table thead th {
|
||||
@@ -2861,6 +2866,10 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody:hover {
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
}
|
||||
|
||||
@@ -2871,23 +2880,24 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-track {
|
||||
background: var(--c2-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
background-clip: content-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-thumb:hover {
|
||||
.c2-events-table tbody:hover::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
}
|
||||
|
||||
.c2-events-table tbody:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--c2-text-muted) 78%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.c2-events-table thead th {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+3175
-192
File diff suppressed because it is too large
Load Diff
+125
-5
@@ -508,6 +508,8 @@
|
||||
"settingsIntroTitle": "Project settings",
|
||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||
"pinProject": "Pin project (show first in list)",
|
||||
"pinProjectAction": "Pin project",
|
||||
"unpinProjectAction": "Unpin project",
|
||||
"pinFact": "Pin fact (prioritize in list and blackboard index)",
|
||||
"editDescriptionPlaceholder": "Client/task notes, contacts, collaboration…",
|
||||
"scopeTitle": "Test scope",
|
||||
@@ -521,6 +523,7 @@
|
||||
"archiveRestore": "Archive / Restore",
|
||||
"archiveProject": "Archive",
|
||||
"editProject": "Edit",
|
||||
"renameProject": "Rename",
|
||||
"restoreProjectActive": "Restore to active",
|
||||
"projectActions": "Project actions",
|
||||
"deleteProject": "Delete project",
|
||||
@@ -538,11 +541,44 @@
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "New chat",
|
||||
"newTask": "New task",
|
||||
"toggleConversationPanel": "Collapse/expand conversation list",
|
||||
"searchHistory": "Search history...",
|
||||
"projectFolders": "Projects",
|
||||
"projectFoldersLoadMoreRemaining": "Load more, {{count}} projects remaining",
|
||||
"projectPreviewLabel": "Project information",
|
||||
"projectPreviewStats": "{{total}} tasks · {{active}} open",
|
||||
"projectPreviewNoDescription": "No project description",
|
||||
"projectPreviewScope": "Test scope: {{scope}}",
|
||||
"projectPreviewEdit": "Edit project",
|
||||
"conversationPreviewJustNow": "Now",
|
||||
"conversationPreviewMinutes": "{{count}} min",
|
||||
"conversationPreviewHours": "{{count}}h",
|
||||
"conversationPreviewDays": "{{count}}d",
|
||||
"conversationPreviewDateTime": "{{year}}-{{month}}-{{day}} {{hour}}:{{minute}}",
|
||||
"conversationPreviewNoProject": "No project",
|
||||
"conversationPreviewDefaultMode": "Default",
|
||||
"conversationPreviewUnread": "Unread update",
|
||||
"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",
|
||||
"conversationActions": "Conversation actions",
|
||||
"renameConversationPrompt": "Enter a new title:",
|
||||
"renameConversationTitle": "Rename conversation",
|
||||
"renameConversationSubtitle": "The name will update in project folders and recent conversations",
|
||||
"conversationTitleLabel": "Conversation name",
|
||||
"conversationTitlePlaceholder": "Enter a conversation name",
|
||||
"conversationGroups": "Conversation groups",
|
||||
"addGroup": "New group",
|
||||
"recentConversations": "Recent conversations",
|
||||
"toggleRecentConversations": "Expand/collapse recent conversations",
|
||||
"filterByProject": "Filter by project",
|
||||
"filterAllProjects": "All projects",
|
||||
"filterUnboundProjects": "Unbound",
|
||||
@@ -571,7 +607,7 @@
|
||||
"viewAttackChain": "View attack chain",
|
||||
"selectRole": "Select role",
|
||||
"defaultRole": "Default",
|
||||
"inputPlaceholder": "Enter target or command... (type @ to select tools | Shift+Enter newline, Enter send)",
|
||||
"inputPlaceholder": "Enter a target or command… @ select tools",
|
||||
"selectFile": "Select file",
|
||||
"uploadFile": "Upload file (multi-select or drag & drop)",
|
||||
"readingAttachmentsDetail": "Reading attachment {{current}}/{{total}} · {{name}} · {{percent}}%",
|
||||
@@ -587,10 +623,20 @@
|
||||
"noMatchTools": "No matching tools",
|
||||
"penetrationTestDetail": "Task execution details",
|
||||
"expandDetail": "Expand details",
|
||||
"turnElapsedRunning": "Processed for {{duration}}",
|
||||
"turnElapsedComplete": "Took {{duration}}",
|
||||
"turnElapsedCancelled": "Interrupted · Took {{duration}}",
|
||||
"turnElapsedTimeout": "Timed out · Took {{duration}}",
|
||||
"turnElapsedFailed": "Failed · Took {{duration}}",
|
||||
"turnDurationSeconds": "{{seconds}} sec",
|
||||
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
|
||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
||||
"turnNumber": "Turn {{number}}",
|
||||
"turnPending": "Processing…",
|
||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||
"loadingEarlierDetails": "Loading earlier entries…",
|
||||
"loadingLaterDetails": "Loading newer entries…",
|
||||
"backToLatestProgress": "↓ Back to latest",
|
||||
"viewToolDetail": "View details",
|
||||
"collapseToolDetail": "Collapse",
|
||||
"liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.",
|
||||
@@ -617,6 +663,12 @@
|
||||
"executeFailed": "Execution failed",
|
||||
"callOpenAIFailed": "Call OpenAI failed",
|
||||
"systemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
|
||||
"projectWelcomeMessage": "Current project: {{project}}. Enter your test requirements and the system will run the corresponding security tests.",
|
||||
"noProjectWelcomeMessage": "No project is currently selected. Enter your test requirements and the system will run the corresponding security tests.",
|
||||
"projectWelcomeTitlePrefix": "What should be tested in ",
|
||||
"projectWelcomeTitleSuffix": "?",
|
||||
"noProjectWelcomeTitle": "What should be tested?",
|
||||
"welcomeSubtitle": "Enter your test requirements and the system will automatically run the corresponding security tests.",
|
||||
"addNewGroup": "+ New group",
|
||||
"callNumber": "Call #{{n}}",
|
||||
"iterationRound": "Iteration {{n}}",
|
||||
@@ -674,15 +726,16 @@
|
||||
"loadFailedRetry": "Load failed, please retry",
|
||||
"dataFormatError": "Data format error",
|
||||
"progressInProgress": "Penetration test in progress...",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"scrollToBottomHasNew": "↓ New content below",
|
||||
"scrollToBottomNew": "↓ {{count}} new update(s)",
|
||||
"executionFailed": "Execution failed",
|
||||
"penetrationTestComplete": "Penetration test complete",
|
||||
"yesterday": "Yesterday",
|
||||
"historyGroupToday": "Today",
|
||||
"historyGroupLast7Days": "Past 7 days",
|
||||
"historyGroupEarlier": "Older",
|
||||
"conversationPreviewJustNow": "Just now",
|
||||
"conversationPreviewMinutes": "{{count}} min",
|
||||
"conversationPreviewHours": "{{count}} hr",
|
||||
"conversationPreviewDays": "{{count}} days",
|
||||
"agentModeSelectAria": "Choose conversation execution mode",
|
||||
"agentModePanelTitle": "Conversation mode",
|
||||
"agentModeEinoSingle": "Eino single (ADK)",
|
||||
@@ -711,6 +764,21 @@
|
||||
"sessionSettingsTitle": "Session settings",
|
||||
"sessionSettingsAria": "Open session settings",
|
||||
"sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.",
|
||||
"sessionShortcutAuditAgent": "Agent review",
|
||||
"modelSettingsAria": "Choose model and reasoning effort",
|
||||
"systemModelPickerTitle": "Choose system model",
|
||||
"systemModelField": "Model",
|
||||
"systemModelLoading": "Fetching model list…",
|
||||
"systemModelLoaded": "Loaded {count} models",
|
||||
"systemModelCurrent": "Current",
|
||||
"systemModelSaving": "Saving…",
|
||||
"systemModelSaved": "Saved automatically",
|
||||
"systemModelLoadFailed": "Failed to fetch models",
|
||||
"systemModelSaveFailed": "Failed to save model",
|
||||
"systemModelApplyFailed": "Failed to apply model",
|
||||
"systemModelNeedApiKey": "Configure an API key in System Settings first",
|
||||
"systemModelRetry": "Try again",
|
||||
"sessionShortcutHuman": "Human approval",
|
||||
"aiChannelLabel": "AI channel",
|
||||
"aiChannelDefault": "Use default channel",
|
||||
"aiChannelDefaultShort": "Default channel",
|
||||
@@ -738,6 +806,12 @@
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
"hitlTimeoutLabel": "Approval wait limit",
|
||||
"hitlTimeoutOneMinute": "1 minute",
|
||||
"hitlTimeoutFiveMinutes": "5 minutes",
|
||||
"hitlTimeoutTenMinutes": "10 minutes",
|
||||
"hitlTimeoutUnlimited": "No limit",
|
||||
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||
},
|
||||
"hitl": {
|
||||
@@ -763,6 +837,52 @@
|
||||
"tabStrategy": "Audit strategy",
|
||||
"tabWhitelist": "Tool whitelist",
|
||||
"pendingTitle": "Pending approvals",
|
||||
"auditReviewing": "Automatic review in progress",
|
||||
"auditReviewEditing": "Automatically reviewing and correcting",
|
||||
"auditReviewExplanation": "A carefully prompted review agent is reviewing this request. It will run only after approval.",
|
||||
"auditApproved": "Audit Agent approved",
|
||||
"auditEditedApproved": "Audit Agent edited parameters and approved",
|
||||
"auditRejected": "Audit Agent rejected",
|
||||
"waitingHumanApproval": "Waiting for human approval",
|
||||
"waitingHumanReview": "Waiting for human review",
|
||||
"humanApprovalExplanation": "This tool call needs your confirmation before it can run.",
|
||||
"humanReviewExplanation": "Review and optionally edit the parameters before allowing execution.",
|
||||
"humanApproved": "Allowed once",
|
||||
"humanEditedApproved": "Edited parameters and allowed",
|
||||
"humanRejected": "Human approval rejected",
|
||||
"viewEditedArgs": "View edited parameters",
|
||||
"reviewArgs": "Review parameters (JSON)",
|
||||
"commentOptional": "Comment (optional)",
|
||||
"commentPlaceholder": "For example: read-only operations only",
|
||||
"reject": "Reject",
|
||||
"allowOnce": "Allow once",
|
||||
"saveEditedAndAllow": "Save edits and allow",
|
||||
"waitingApprovalShort": "Waiting for approval",
|
||||
"waitingApprovalCount": "Waiting approval {{count}}",
|
||||
"approvalUrgencyUnlimited": "Approval has no time limit",
|
||||
"approvalUrgencyMoreThanThree": "Earliest approval expires in more than 3 minutes",
|
||||
"approvalUrgencyMoreThanFive": "Earliest approval expires in more than 5 minutes",
|
||||
"approvalUrgencyThreeToFive": "Earliest approval expires in 3–5 minutes",
|
||||
"approvalUrgencyOneToThree": "Earliest approval expires in 1–3 minutes",
|
||||
"approvalUrgencyWithinOne": "Earliest approval expires within 1 minute",
|
||||
"requestGeneric": "Allow CyberStrikeAI to call {{tool}}?",
|
||||
"requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?",
|
||||
"requestBrowser": "Allow CyberStrikeAI to use the browser?",
|
||||
"requestCommand": "Allow CyberStrikeAI to run this command?",
|
||||
"requestFile": "Allow CyberStrikeAI to modify {{path}}?",
|
||||
"requestFiles": "Allow CyberStrikeAI to modify files?",
|
||||
"toolTerminal": "Terminal",
|
||||
"toolFiles": "Files",
|
||||
"viewRequestDetails": "View request details",
|
||||
"editRequestDetails": "View or edit request parameters",
|
||||
"addApprovalComment": "Add approval comment (optional)",
|
||||
"timeoutAutoReject": "Automatically rejects at expiry",
|
||||
"timeoutUnlimited": "No time limit",
|
||||
"expiredAutoRejected": "Approval timed out; rejecting automatically…",
|
||||
"taskClosedApprovalUnavailable": "Task ended; approval is unavailable",
|
||||
"taskInterrupted": "Task interrupted",
|
||||
"interruptedApprovalCancelled": "Task interrupted; approval cancelled",
|
||||
"expiredRejected": "Approval timed out and was rejected",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Tool, conversation, payload, comment…",
|
||||
"searchApply": "Search",
|
||||
|
||||
+125
-5
@@ -496,6 +496,8 @@
|
||||
"settingsIntroTitle": "项目设置",
|
||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||
"pinProject": "置顶项目(列表优先显示)",
|
||||
"pinProjectAction": "置顶项目",
|
||||
"unpinProjectAction": "取消置顶",
|
||||
"pinFact": "置顶事实(列表与黑板索引优先)",
|
||||
"editDescriptionPlaceholder": "客户/任务备注、协作说明、联系人…",
|
||||
"scopeTitle": "测试范围",
|
||||
@@ -509,6 +511,7 @@
|
||||
"archiveRestore": "归档 / 恢复",
|
||||
"archiveProject": "归档",
|
||||
"editProject": "编辑",
|
||||
"renameProject": "重命名",
|
||||
"restoreProjectActive": "恢复为进行中",
|
||||
"projectActions": "项目操作",
|
||||
"deleteProject": "删除项目",
|
||||
@@ -526,11 +529,44 @@
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "新对话",
|
||||
"newTask": "新任务",
|
||||
"toggleConversationPanel": "折叠/展开对话列表",
|
||||
"searchHistory": "搜索历史记录...",
|
||||
"projectFolders": "项目",
|
||||
"projectFoldersLoadMoreRemaining": "加载更多,剩余 {{count}} 个项目",
|
||||
"projectPreviewLabel": "项目信息",
|
||||
"projectPreviewStats": "{{total}} 个任务 · {{active}} 个已开启",
|
||||
"projectPreviewNoDescription": "暂无项目说明",
|
||||
"projectPreviewScope": "测试范围:{{scope}}",
|
||||
"projectPreviewEdit": "编辑项目",
|
||||
"conversationPreviewJustNow": "刚刚",
|
||||
"conversationPreviewMinutes": "{{count}} 分钟",
|
||||
"conversationPreviewHours": "{{count}} 小时",
|
||||
"conversationPreviewDays": "{{count}} 天",
|
||||
"conversationPreviewDateTime": "{{year}}年{{month}}月{{day}}日 {{hour}}:{{minute}}",
|
||||
"conversationPreviewNoProject": "未绑定项目",
|
||||
"conversationPreviewDefaultMode": "默认",
|
||||
"conversationPreviewUnread": "有未读更新",
|
||||
"conversationPreviewViewed": "已查看",
|
||||
"conversationPreviewConversation": "对话",
|
||||
"returnToLatest": "回到最新消息",
|
||||
"taskProgressStep": "第 {{current}} / {{total}} 步",
|
||||
"taskProgressOpen": "查看任务进度",
|
||||
"taskProgressDetails": "任务进度详情",
|
||||
"taskProgressUnnamed": "未命名任务",
|
||||
"completedUnread": "已完成,尚未查看",
|
||||
"newConversationInProject": "在此项目中新建对话",
|
||||
"newUnassignedConversation": "新建无项目对话",
|
||||
"conversationActions": "对话操作",
|
||||
"renameConversationPrompt": "请输入新标题:",
|
||||
"renameConversationTitle": "重命名对话",
|
||||
"renameConversationSubtitle": "修改后会同步更新项目文件夹和最近对话中的名称",
|
||||
"conversationTitleLabel": "对话名称",
|
||||
"conversationTitlePlaceholder": "请输入对话名称",
|
||||
"conversationGroups": "对话分组",
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"toggleRecentConversations": "展开/折叠最近对话",
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
@@ -559,7 +595,7 @@
|
||||
"viewAttackChain": "查看攻击链",
|
||||
"selectRole": "选择角色",
|
||||
"defaultRole": "默认",
|
||||
"inputPlaceholder": "输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)",
|
||||
"inputPlaceholder": "输入测试目标或命令… @ 选择工具",
|
||||
"selectFile": "选择文件",
|
||||
"uploadFile": "上传文件(可多选或拖拽到此处)",
|
||||
"readingAttachmentsDetail": "读取附件 {{current}}/{{total}} · {{name}} · {{percent}}%",
|
||||
@@ -575,10 +611,20 @@
|
||||
"noMatchTools": "没有匹配的工具",
|
||||
"penetrationTestDetail": "任务执行详情",
|
||||
"expandDetail": "展开详情",
|
||||
"turnElapsedRunning": "已处理 {{duration}}",
|
||||
"turnElapsedComplete": "耗时 {{duration}}",
|
||||
"turnElapsedCancelled": "已中断 · 耗时 {{duration}}",
|
||||
"turnElapsedTimeout": "已超时 · 耗时 {{duration}}",
|
||||
"turnElapsedFailed": "执行失败 · 耗时 {{duration}}",
|
||||
"turnDurationSeconds": "{{seconds}} 秒",
|
||||
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
|
||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||
"turnNumber": "第 {{number}} 轮",
|
||||
"turnPending": "正在处理…",
|
||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||
"loadingEarlierDetails": "正在加载更早记录…",
|
||||
"loadingLaterDetails": "正在加载更新记录…",
|
||||
"backToLatestProgress": "↓ 回到最新进度",
|
||||
"viewToolDetail": "查看详情",
|
||||
"collapseToolDetail": "收起",
|
||||
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
|
||||
@@ -605,6 +651,12 @@
|
||||
"executeFailed": "执行失败",
|
||||
"callOpenAIFailed": "调用OpenAI失败",
|
||||
"systemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"projectWelcomeMessage": "当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"noProjectWelcomeMessage": "当前无项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"projectWelcomeTitlePrefix": "要在 ",
|
||||
"projectWelcomeTitleSuffix": " 项目中测试什么?",
|
||||
"noProjectWelcomeTitle": "要测试什么?",
|
||||
"welcomeSubtitle": "请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"addNewGroup": "+ 新增分组",
|
||||
"callNumber": "调用 #{{n}}",
|
||||
"iterationRound": "第 {{n}} 轮迭代",
|
||||
@@ -662,15 +714,16 @@
|
||||
"loadFailedRetry": "加载失败,请重试",
|
||||
"dataFormatError": "数据格式错误",
|
||||
"progressInProgress": "渗透测试进行中...",
|
||||
"scrollToBottom": "回到底部",
|
||||
"scrollToBottomHasNew": "↓ 有新内容",
|
||||
"scrollToBottomNew": "↓ {{count}} 条新内容",
|
||||
"executionFailed": "执行失败",
|
||||
"penetrationTestComplete": "渗透测试完成",
|
||||
"yesterday": "昨天",
|
||||
"historyGroupToday": "今天",
|
||||
"historyGroupLast7Days": "过去七天",
|
||||
"historyGroupEarlier": "更早",
|
||||
"conversationPreviewJustNow": "刚刚",
|
||||
"conversationPreviewMinutes": "{{count}} 分钟",
|
||||
"conversationPreviewHours": "{{count}} 小时",
|
||||
"conversationPreviewDays": "{{count}} 天",
|
||||
"agentModeSelectAria": "选择对话执行模式",
|
||||
"agentModePanelTitle": "对话模式",
|
||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||
@@ -699,6 +752,21 @@
|
||||
"sessionSettingsTitle": "会话设置",
|
||||
"sessionSettingsAria": "打开会话设置",
|
||||
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
|
||||
"sessionShortcutAuditAgent": "Agent 审查",
|
||||
"modelSettingsAria": "选择模型与推理强度",
|
||||
"systemModelPickerTitle": "选择系统模型",
|
||||
"systemModelField": "模型",
|
||||
"systemModelLoading": "正在获取模型列表…",
|
||||
"systemModelLoaded": "已获取 {count} 个模型",
|
||||
"systemModelCurrent": "当前",
|
||||
"systemModelSaving": "正在保存…",
|
||||
"systemModelSaved": "已自动保存",
|
||||
"systemModelLoadFailed": "获取模型失败",
|
||||
"systemModelSaveFailed": "保存模型失败",
|
||||
"systemModelApplyFailed": "应用模型失败",
|
||||
"systemModelNeedApiKey": "请先在系统设置中配置 API Key",
|
||||
"systemModelRetry": "重新获取",
|
||||
"sessionShortcutHuman": "人工审批",
|
||||
"aiChannelLabel": "AI 通道",
|
||||
"aiChannelDefault": "跟随默认通道",
|
||||
"aiChannelDefaultShort": "默认通道",
|
||||
@@ -726,6 +794,12 @@
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
"hitlTimeoutLabel": "审批等待时限",
|
||||
"hitlTimeoutOneMinute": "1 分钟",
|
||||
"hitlTimeoutFiveMinutes": "5 分钟",
|
||||
"hitlTimeoutTenMinutes": "10 分钟",
|
||||
"hitlTimeoutUnlimited": "不限制",
|
||||
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
|
||||
"hitlStatusOff": "人机协同:关闭"
|
||||
},
|
||||
"hitl": {
|
||||
@@ -751,6 +825,52 @@
|
||||
"tabStrategy": "审计策略",
|
||||
"tabWhitelist": "工具白名单",
|
||||
"pendingTitle": "待处理审批",
|
||||
"auditReviewing": "自动审核中",
|
||||
"auditReviewEditing": "自动审查并校正中",
|
||||
"auditReviewExplanation": "经过谨慎提示的审查智能体正在审查此请求,通过后才会执行。",
|
||||
"auditApproved": "审计 Agent 已批准",
|
||||
"auditEditedApproved": "审计 Agent 已修改参数并批准",
|
||||
"auditRejected": "审计 Agent 已拒绝",
|
||||
"waitingHumanApproval": "等待人工审批",
|
||||
"waitingHumanReview": "等待人工审查",
|
||||
"humanApprovalExplanation": "此工具调用需要你的确认,通过后才会执行。",
|
||||
"humanReviewExplanation": "请审查并可修改参数,保存后才会执行。",
|
||||
"humanApproved": "已允许一次",
|
||||
"humanEditedApproved": "已修改参数并允许",
|
||||
"humanRejected": "人工审批已拒绝",
|
||||
"viewEditedArgs": "查看修改后的参数",
|
||||
"reviewArgs": "审查参数(JSON)",
|
||||
"commentOptional": "备注(可选)",
|
||||
"commentPlaceholder": "例如:仅允许只读操作",
|
||||
"reject": "拒绝",
|
||||
"allowOnce": "允许一次",
|
||||
"saveEditedAndAllow": "保存修改并允许",
|
||||
"waitingApprovalShort": "等待批准",
|
||||
"waitingApprovalCount": "等待批准 {{count}}",
|
||||
"approvalUrgencyUnlimited": "审批不限时",
|
||||
"approvalUrgencyMoreThanThree": "最早审批将在 3 分钟后到期",
|
||||
"approvalUrgencyMoreThanFive": "最早审批将在 5 分钟后到期",
|
||||
"approvalUrgencyThreeToFive": "最早审批将在 3–5 分钟内到期",
|
||||
"approvalUrgencyOneToThree": "最早审批将在 1–3 分钟内到期",
|
||||
"approvalUrgencyWithinOne": "最早审批将在 1 分钟内到期",
|
||||
"requestGeneric": "允许 CyberStrikeAI 调用 {{tool}}?",
|
||||
"requestVisitUrl": "允许 CyberStrikeAI 访问 {{url}}?",
|
||||
"requestBrowser": "允许 CyberStrikeAI 使用浏览器?",
|
||||
"requestCommand": "允许 CyberStrikeAI 执行这条命令?",
|
||||
"requestFile": "允许 CyberStrikeAI 修改 {{path}}?",
|
||||
"requestFiles": "允许 CyberStrikeAI 修改文件?",
|
||||
"toolTerminal": "终端",
|
||||
"toolFiles": "文件",
|
||||
"viewRequestDetails": "查看请求详情",
|
||||
"editRequestDetails": "查看或修改请求参数",
|
||||
"addApprovalComment": "添加审批备注(可选)",
|
||||
"timeoutAutoReject": "到期自动拒绝",
|
||||
"timeoutUnlimited": "不限时等待",
|
||||
"expiredAutoRejected": "审批已超时,正在自动拒绝…",
|
||||
"taskClosedApprovalUnavailable": "任务已结束,审批不可用",
|
||||
"taskInterrupted": "任务已中断",
|
||||
"interruptedApprovalCancelled": "任务已中断,审批已取消",
|
||||
"expiredRejected": "审批超时,已自动拒绝",
|
||||
"searchLabel": "搜索",
|
||||
"searchPlaceholder": "工具名、会话 ID、载荷、备注…",
|
||||
"searchApply": "搜索",
|
||||
|
||||
@@ -333,6 +333,15 @@ async function refreshAppData(showTaskErrors = false) {
|
||||
loadConversations(),
|
||||
loadActiveTasks(showTaskErrors),
|
||||
]);
|
||||
// 未登录首屏的项目侧栏可能先收到 401 并显示失败;认证完成后必须主动重试。
|
||||
// 放在对话/任务刷新之后,确保最终渲染一定使用有效登录态且不会被早期失败覆盖。
|
||||
if (typeof window.refreshChatProjectSelector === 'function') {
|
||||
try {
|
||||
await window.refreshChatProjectSelector({ reloadFolders: true });
|
||||
} catch (error) {
|
||||
console.warn('刷新项目侧栏失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
||||
|
||||
test('主对话时间线不再创建用户或助手头像', () => {
|
||||
assert.doesNotMatch(chat, /createMessageAvatar/);
|
||||
assert.doesNotMatch(monitor, /createMessageAvatar/);
|
||||
assert.doesNotMatch(chat, /message-avatar/);
|
||||
assert.doesNotMatch(styles, /\.message-avatar/);
|
||||
});
|
||||
|
||||
test('新对话使用无图标的项目欢迎空状态', () => {
|
||||
assert.match(chat, /function renderChatWelcomeEmptyState\(\)/);
|
||||
assert.match(chat, /chat-welcome-empty-state-title/);
|
||||
assert.match(chat, /chat-welcome-empty-state-subtitle/);
|
||||
assert.doesNotMatch(chat, /chat-welcome-empty-state-icon/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state\s*\{[\s\S]*?justify-content: center/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-title/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-subtitle/);
|
||||
assert.match(styles, /\.chat-welcome-project-name\s*\{[\s\S]*?border-bottom: 1px dotted currentColor/);
|
||||
assert.match(chat, /projectName\.className = 'chat-welcome-project-name'/);
|
||||
assert.match(chat, /title\.replaceChildren\(/);
|
||||
});
|
||||
|
||||
test('欢迎语随项目和无项目状态更新', () => {
|
||||
assert.match(chat, /window\.t\('chat\.projectWelcomeMessage', \{ project \}\)/);
|
||||
assert.match(chat, /window\.t\('chat\.noProjectWelcomeMessage'\)/);
|
||||
assert.match(projects, /window\.refreshChatWelcomeEmptyState\(\)/);
|
||||
assert.equal(
|
||||
zh.chat.projectWelcomeMessage,
|
||||
'当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。'
|
||||
);
|
||||
assert.equal(zh.chat.projectWelcomeTitlePrefix, '要在 ');
|
||||
assert.equal(zh.chat.projectWelcomeTitleSuffix, ' 项目中测试什么?');
|
||||
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,83 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createKeydownHarness() {
|
||||
const context = {
|
||||
isComposing: false,
|
||||
mentionState: { active: false },
|
||||
mentionSuggestionsEl: null,
|
||||
sendCount: 0,
|
||||
sendMessage() {
|
||||
context.sendCount += 1;
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${functionSource(chat, 'handleChatInputKeydown', 'updateMentionStateFromInput')}; this.handleChatInputKeydown = handleChatInputKeydown;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('聊天输入框按 Enter 发送并阻止原生换行', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(context.sendCount, 1);
|
||||
});
|
||||
|
||||
test('聊天输入框按 Shift+Enter 只换行且不发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: true,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
|
||||
test('输入法确认候选词时按 Enter 不会发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: true,
|
||||
keyCode: 229,
|
||||
preventDefault() {
|
||||
throw new Error('IME Enter should not be prevented');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
||||
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createScrollRuntime() {
|
||||
const listeners = new Map();
|
||||
const buttonListeners = new Map();
|
||||
const classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
|
||||
const chatEl = {
|
||||
scrollTop: 500,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
children: [],
|
||||
classList,
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
scrollTo(options) { this.scrollTop = Number(options && options.top) || 0; },
|
||||
getBoundingClientRect() { return { right: 1000 }; },
|
||||
};
|
||||
const returnLatest = {
|
||||
hidden: true,
|
||||
classList,
|
||||
addEventListener(type, handler) { buttonListeners.set(type, handler); },
|
||||
blur() {},
|
||||
};
|
||||
const rafQueue = new Map();
|
||||
let rafId = 0;
|
||||
const requestAnimationFrame = (handler) => {
|
||||
const id = ++rafId;
|
||||
rafQueue.set(id, handler);
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id) => rafQueue.delete(id);
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
getElementById(id) {
|
||||
if (id === 'chat-messages') return chatEl;
|
||||
if (id === 'chat-return-latest') return returnLatest;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
document,
|
||||
addEventListener() {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
};
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(scroll, context);
|
||||
return {
|
||||
api: window.CyberStrikeChatScroll,
|
||||
chatEl,
|
||||
listeners,
|
||||
flushAnimationFrames() {
|
||||
while (rafQueue.size) {
|
||||
const pending = Array.from(rafQueue.values());
|
||||
rafQueue.clear();
|
||||
pending.forEach((handler) => handler(Date.now()));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.listeners.get('wheel')({ deltaY: -20 });
|
||||
runtime.chatEl.scrollTop = 480;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false);
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 480, '新输出不能抢回用户的阅读位置');
|
||||
|
||||
runtime.chatEl.scrollTop = 597;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false, '距底部 2px 以上仍保持脱离');
|
||||
|
||||
runtime.chatEl.scrollTop = 600;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '用户滚到真实底部后立即恢复跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1200;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
|
||||
});
|
||||
|
||||
test('刷新重建详情引起的布局上移不会误判为用户上滑', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.chatEl.scrollTop = 460;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '没有用户输入的布局滚动仍应保持跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1100, '刷新恢复后的后续增量应继续粘底');
|
||||
});
|
||||
|
||||
test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
|
||||
const refreshSource = functionSource(auth, 'refreshAppData', 'bootstrapApp');
|
||||
const conversationsIndex = refreshSource.indexOf('loadConversations()');
|
||||
const projectRetryIndex = refreshSource.indexOf('window.refreshChatProjectSelector({ reloadFolders: true })');
|
||||
|
||||
assert.notEqual(conversationsIndex, -1);
|
||||
assert.ok(projectRetryIndex > conversationsIndex);
|
||||
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
|
||||
});
|
||||
|
||||
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
|
||||
const resumeSource = functionSource(scroll, 'resumeFollowingIfAtBottom', 'captureScrollPinState');
|
||||
const captureSource = functionSource(scroll, 'captureScrollPinState', 'setScrollFollowing');
|
||||
const autoSource = functionSource(scroll, 'canAutoScrollNow', 'scheduleChatScrollToBottomIfFollowing');
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
|
||||
assert.match(resumeSource, /thresholdPx/);
|
||||
assert.match(scroll, /CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.doesNotMatch(captureSource, /resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(autoSource, /resumeFollowingIfAtBottom/);
|
||||
assert.match(resumeSource, /if \(!userInitiated\) return false/);
|
||||
assert.match(scrollSource, /scrolledDown/);
|
||||
assert.match(scrollSource, /hasUserScrollIntent/);
|
||||
assert.match(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true\)/);
|
||||
assert.doesNotMatch(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.doesNotMatch(scrollSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(scrollSource, /contentShrank/);
|
||||
assert.match(scrollSource, /sh < lastScrollHeight - 1/);
|
||||
assert.match(scrollSource, /if \(scrolledUp && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)\) \{[\s\S]*?setScrollDetached\(\)/);
|
||||
assert.match(scrollSource, /if \(programmaticScroll\) \{[\s\S]*?st < lastScrollTop - 1 && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)[\s\S]*?setScrollDetached\(\)/);
|
||||
});
|
||||
|
||||
test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
const selectModeSource = functionSource(chat, 'selectAgentMode', 'initChatAgentModeFromConfig');
|
||||
|
||||
assert.match(scroll, /let userScrollIntentUntil = 0/);
|
||||
assert.match(scrollSource, /const hasUserScrollIntent = Date\.now\(\) <= userScrollIntentUntil/);
|
||||
assert.match(scrollSource, /scrolledDown &&[\s\S]*?hasUserScrollIntent &&[\s\S]*?resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /userScrollIntentUntil = Date\.now\(\) \+ 1800/);
|
||||
assert.doesNotMatch(selectModeSource, /setScrollFollowing|forceScrollToBottom|scrollTop/);
|
||||
});
|
||||
|
||||
test('刷新运行中任务补齐最新详情后保持粘底但尊重用户上滑', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const settleSource = functionSource(scroll, 'settleChatToBottomIfFollowing', 'scrollChatMessagesToBottomIfPinned');
|
||||
|
||||
assert.match(attachSource, /window\.captureScrollPinState\(\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(12\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(18\)/);
|
||||
assert.match(attachSource, /用户期间没有主动上滑/);
|
||||
assert.match(attachSource, /keepFollowingFinalRender/);
|
||||
assert.match(attachSource, /最终消息和详情重绘都会增高 DOM/);
|
||||
assert.match(settleSource, /scrollMode !== 'following'/);
|
||||
assert.match(settleSource, /Date\.now\(\) < detachLockUntil/);
|
||||
assert.match(settleSource, /settleFrame\(remaining - 1\)/);
|
||||
assert.match(settleSource, /scrollChatToBottomInstant\(\)/);
|
||||
assert.match(scroll, /function settleConversationRestoreToBottom\(frameCount\)/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000/);
|
||||
assert.match(scroll, /const generation = \+\+conversationRestoreGeneration/);
|
||||
assert.match(scroll, /scrollMode !== 'following'/);
|
||||
assert.match(scroll, /stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES/);
|
||||
assert.match(scroll, /requestAnimationFrame\(settleRestoreFrame\)/);
|
||||
assert.match(chat, /settleConversationRestoreToBottom\(30\)/);
|
||||
});
|
||||
|
||||
test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解除', () => {
|
||||
const startSource = functionSource(monitor, 'startProcessDetailsLatestFollow', 'loadProcessDetailsPaginated');
|
||||
const loadSource = functionSource(monitor, 'loadProcessDetailsPaginated', 'shouldInitiallyOpenProcessDetailsAtLatest');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /characterData: true/);
|
||||
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
|
||||
assert.match(startSource, /event\.deltaY < -1/);
|
||||
assert.match(startSource, /state\.userScrollIntentUntil = Date\.now\(\) \+ 1200/);
|
||||
assert.match(startSource, /event\.clientX >= rect\.right - PROCESS_DETAILS_FOLLOW_SCROLLBAR_GUTTER_PX/);
|
||||
assert.match(startSource, /event\.key === 'ArrowUp'/);
|
||||
assert.match(startSource, /cancelAnimationFrame\(state\.rafId\)/);
|
||||
assert.match(startSource, /if \(scrolledUp && \(state\.detached \|\| Date\.now\(\) <= state\.userScrollIntentUntil\)\) \{[\s\S]*?detachForUserNavigation\(\)/);
|
||||
assert.match(startSource, /state\.detached &&[\s\S]*?scrolledDown &&[\s\S]*?Date\.now\(\) <= state\.userScrollIntentUntil/);
|
||||
assert.match(monitor, /PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.match(startSource, /distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
|
||||
assert.match(startSource, /state\.detached = false/);
|
||||
assert.doesNotMatch(startSource, /if \(distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX\) \{\s*state\.detached = false/);
|
||||
assert.match(loadSource, /startProcessDetailsLatestFollow\(assistantMessageId/);
|
||||
assert.match(attachSource, /startProcessDetailsLatestFollow\(asEl\.id, \{ persistent: true \}\)/);
|
||||
assert.match(attachSource, /stopProcessDetailsLatestFollow\(asEl\.id\)/);
|
||||
});
|
||||
|
||||
test('刷新后的工具调用恢复与实时一致的成功失败徽标', () => {
|
||||
const renderSource = functionSource(chat, 'renderProcessDetails', 'finishProcessDetailsRender');
|
||||
const presentationSource = functionSource(monitor, 'getToolCallStatusPresentation', 'applyToolCallStatus');
|
||||
const applySource = functionSource(monitor, 'applyToolCallStatus', 'updateToolCallStatus');
|
||||
const addSource = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(renderSource, /toolStatusByProcessDetailId/);
|
||||
assert.match(renderSource, /timelineOpts\.toolStatus = toolStatusByProcessDetailId\.get/);
|
||||
assert.match(presentationSource, /normalized === 'completed'/);
|
||||
assert.match(presentationSource, /normalized === 'failed'/);
|
||||
assert.match(applySource, /tool-status-badge/);
|
||||
assert.match(applySource, /item\.dataset\.toolDisplayStatus = presentation\.status/);
|
||||
assert.match(addSource, /initialToolStatus = item\.dataset\.toolDisplayStatus/);
|
||||
assert.match(addSource, /applyToolCallStatus\(item, initialToolStatus\)/);
|
||||
assert.match(monitor, /refreshProgressAndTimelineI18n\(\)[\s\S]*?applyToolCallStatus\(item, item\.dataset\.toolDisplayStatus\)/);
|
||||
});
|
||||
|
||||
test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最新内容', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const addSource = functionSource(monitor, 'addProgressMessage', 'toggleProgressDetails');
|
||||
const liveSource = functionSource(monitor, 'startLiveProgressLatestFollow', 'stopLiveProgressLatestFollow');
|
||||
|
||||
assert.match(css, /\.progress-container\.is-streaming \.progress-timeline\.expanded,[\s\S]{0,360}max-height: min\(64vh, 720px\);[\s\S]{0,180}overflow-y: auto;/);
|
||||
assert.match(css, /\.message\.progress-message \.progress-timeline\.expanded \{[\s\S]{0,260}max-height: min\(64vh, 720px\);[\s\S]{0,160}overflow-y: auto;/);
|
||||
assert.doesNotMatch(css, /流式执行中[\s\S]{0,320}overflow-y: visible;/);
|
||||
assert.match(addSource, /startLiveProgressLatestFollow\(id\)/);
|
||||
assert.match(liveSource, /stateKey: liveProgressLatestFollowKey\(id\)/);
|
||||
assert.match(liveSource, /persistent: true/);
|
||||
assert.match(liveSource, /target\.scrollTop = Math\.max\(0, target\.scrollHeight - target\.clientHeight\)/);
|
||||
assert.match(monitor, /function finalizeProgressTask\(progressId, finalLabel\) \{[\s\S]{0,120}stopLiveProgressLatestFollow\(progressId\)/);
|
||||
});
|
||||
|
||||
test('同一会话的其他标签页自动补流且发送前阻止重复任务', () => {
|
||||
const syncSource = functionSource(monitor, 'syncVisibleConversationTaskReplay', 'getActiveTaskDisplayName');
|
||||
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
|
||||
|
||||
assert.match(monitor, /new BroadcastChannel\(CHAT_TASK_SYNC_CHANNEL_NAME\)/);
|
||||
assert.match(monitor, /payload\.type !== 'task-started'/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.markRunning\(id\)/);
|
||||
assert.match(syncSource, /await window\.loadConversation\(conversationId\)/);
|
||||
assert.match(syncSource, /return attachRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /syncVisibleConversationTaskReplay\(normalizedTasks\)/);
|
||||
assert.match(sendSource, /await loadActiveTasks\(\)/);
|
||||
assert.match(sendSource, /if \(isCurrentChatTaskActive\(\)\)/);
|
||||
assert.ok(sendSource.indexOf('if (isCurrentChatTaskActive())') < sendSource.indexOf("addMessage('user'"));
|
||||
assert.match(sendSource, /window\.notifyConversationTaskStarted\(streamConversationId\)/);
|
||||
});
|
||||
|
||||
test('刷新补流在订阅竞态或终态帧丢失时从数据库对账最终正文', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const reconcileSource = functionSource(monitor, 'reconcileConversationAfterTaskReplay', 'cancelRunningTaskEventStream');
|
||||
|
||||
assert.match(attachSource, /const eventStreamResponsePromise = apiFetch\(url/);
|
||||
assert.ok(attachSource.indexOf('const eventStreamResponsePromise') < attachSource.indexOf('loadProcessDetailsPaginated'));
|
||||
assert.match(attachSource, /if \(!active\) \{[\s\S]*?assistantMessageNeedsTaskReplayReconcile\(staleAssistant\)[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!response\.ok\) \{[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!replaySawDone\) \{[\s\S]*?reconcileConversationAfterTaskReplay/);
|
||||
assert.match(reconcileSource, /updateAssistantBubbleContent\(assistantEl\.id, finalMessage\.content \|\| '', true\)/);
|
||||
assert.match(reconcileSource, /loadProcessDetailsPaginated\(assistantEl\.id, finalMessage\.id,[\s\S]*?initialLatest: true,[\s\S]*?autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
|
||||
assert.match(bindSource, /scrollMode === 'following'/);
|
||||
assert.match(bindSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.match(bindSource, /\{ childList: true, subtree: true, characterData: true \}/);
|
||||
assert.match(bindSource, /new ResizeObserver/);
|
||||
assert.match(bindSource, /chatMessagesResizeObserver\.observe\(el\)/);
|
||||
assert.match(bindSource, /改变消息区 clientHeight/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /e\.deltaY < -1/);
|
||||
assert.match(bindSource, /e\.clientX >= rect\.right - 18/);
|
||||
assert.match(bindSource, /e\.key === 'ArrowUp'/);
|
||||
});
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260813-6');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260813-9');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
assert.ok(scrollIndex < monitorIndex);
|
||||
});
|
||||
|
||||
test('直接点击项目对话也会写入 hash 以便刷新后恢复并补流', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const syncSource = functionSource(chat, 'syncChatConversationHash', 'getConversationLiteFromCache');
|
||||
const streamSource = functionSource(monitor, 'setCurrentConversationIdFromStream', 'shouldSkipTaskEventReplayAttach');
|
||||
|
||||
assert.match(syncSource, /window\.location\.hash\.split\('\?'\)\[0\] !== '#chat'/);
|
||||
assert.match(syncSource, /#chat\?conversation=/);
|
||||
assert.match(syncSource, /window\.history\.replaceState/);
|
||||
assert.match(loadSource, /syncChatConversationHash\(conversationId\)/);
|
||||
assert.match(streamSource, /window\.syncChatConversationHash\(cid\)/);
|
||||
});
|
||||
|
||||
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
|
||||
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
|
||||
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
assert.match(router, /scheduleChatConversationFromHash\(0\)/);
|
||||
assert.doesNotMatch(router, /scheduleChatConversationFromHash\((200|500)\)/);
|
||||
assert.match(scheduleSource, /setChatConversationRestorePending\(conversationId, true\)/);
|
||||
assert.match(restoreStateSource, /is-conversation-restoring/);
|
||||
assert.match(restoreStateSource, /aria-busy/);
|
||||
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
|
||||
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-4/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
const findSource = functionSource(monitor, 'findRestoredMainResponseStreamItem', 'responseStreamStateFromRestoredItem');
|
||||
const handleSource = functionSource(monitor, 'handleStreamEvent', 'hitlApprovalTranslate');
|
||||
|
||||
assert.match(findSource, /timeline-item-planning/);
|
||||
assert.match(findSource, /dataset\.responseStreamId/);
|
||||
assert.match(handleSource, /case 'response_start':[\s\S]*?findRestoredMainResponseStreamItem/);
|
||||
assert.match(handleSource, /case 'response_delta':[\s\S]*?responseStreamStateFromRestoredItem/);
|
||||
assert.match(monitor, /item\.dataset\.responseStreamId = String\(options\.data\.streamId\)/);
|
||||
});
|
||||
|
||||
test('非仪表盘 hash 首屏在路由确定前隐藏默认仪表盘', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(html, /document\.documentElement\.classList\.add\('initial-route-pending'\)/);
|
||||
assert.match(router, /document\.documentElement\.classList\.remove\('initial-route-pending'\)/);
|
||||
assert.match(css, /html\.initial-route-pending \.content-area \{[\s\S]*?visibility: hidden;/);
|
||||
});
|
||||
|
||||
test('刷新恢复运行中助手消息时隐藏处理中占位且终态正文会重新显示', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
|
||||
|
||||
assert.match(loadSource, /hideAssistantPlaceholder: isAssistantPlaceholder/);
|
||||
assert.match(chat, /bubble\.hidden = true/);
|
||||
assert.match(updateSource, /assistant-placeholder-content/);
|
||||
assert.match(updateSource, /bubble\.hidden = false/);
|
||||
});
|
||||
|
||||
test('刷新补流任务完成后强制折叠自动展开的迭代详情', () => {
|
||||
const collapseSource = functionSource(monitor, 'collapseAllProgressDetails', 'getAssistantId');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(collapseSource, /options/);
|
||||
assert.match(collapseSource, /forceCollapse/);
|
||||
assert.match(collapseSource, /delete detailsContainer\.dataset\.userExpanded/);
|
||||
assert.match(attachSource, /collapseAllProgressDetails\(finalAssistant\.id, progressId, \{ force: true \}\)/);
|
||||
assert.doesNotMatch(attachSource, /if \(keepExpanded\)/);
|
||||
});
|
||||
|
||||
test('暗色模式用户气泡使用协调的深蓝灰层级', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.message\.user \.message-bubble \{[\s\S]*?background: #1b2638;/);
|
||||
assert.match(css, /border-color: rgba\(96, 165, 250, 0\.18\)/);
|
||||
});
|
||||
|
||||
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-6/);
|
||||
});
|
||||
+549
-49
@@ -7,32 +7,322 @@
|
||||
|
||||
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
|
||||
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
/** FAB 隐藏:用户已手动滚近底部 */
|
||||
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
|
||||
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
|
||||
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
|
||||
/** 到达此范围视为位于最后一轮 */
|
||||
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
|
||||
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
|
||||
const DETACH_LOCK_MS = 280;
|
||||
const DETACH_LOCK_MS = 900;
|
||||
/** 刷新恢复会跨越历史消息、过程详情、字体与流订阅等多轮异步布局。 */
|
||||
const CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000;
|
||||
const CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000;
|
||||
const CONVERSATION_RESTORE_STABLE_FRAMES = 12;
|
||||
|
||||
/** @type {'following' | 'detached'} */
|
||||
let scrollMode = 'following';
|
||||
let scrollFollowRaf = 0;
|
||||
let scrollSettleGeneration = 0;
|
||||
let conversationRestoreGeneration = 0;
|
||||
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
|
||||
let hasPendingNewBelow = false;
|
||||
let listenersBound = false;
|
||||
let lastScrollTop = 0;
|
||||
let lastScrollHeight = 0;
|
||||
let programmaticScroll = false;
|
||||
let detachLockUntil = 0;
|
||||
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
|
||||
let userScrollIntentUntil = 0;
|
||||
let turnRailRefreshRaf = 0;
|
||||
let turnRailSignature = '';
|
||||
let activeTurnIndex = -1;
|
||||
let turnRailObserver = null;
|
||||
let chatMessagesResizeObserver = null;
|
||||
let turnPreviewHideTimer = 0;
|
||||
|
||||
function getChatMessagesEl() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
|
||||
function getTurnRailEl() {
|
||||
return document.getElementById('chat-turn-rail');
|
||||
}
|
||||
|
||||
function getTurnRailMarkersEl() {
|
||||
return document.getElementById('chat-turn-rail-markers');
|
||||
}
|
||||
|
||||
function getReturnLatestButton() {
|
||||
return document.getElementById('chat-return-latest');
|
||||
}
|
||||
|
||||
function normalizePreviewText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function trimPreviewText(value, maxLength) {
|
||||
const text = normalizePreviewText(value);
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
function messagePreviewText(messageEl) {
|
||||
if (!messageEl) return '';
|
||||
const original = messageEl.dataset ? messageEl.dataset.originalContent : '';
|
||||
if (original) return normalizePreviewText(original);
|
||||
const bubble = messageEl.querySelector('.assistant-final-result, .message-bubble');
|
||||
if (!bubble) return '';
|
||||
const clone = bubble.cloneNode(true);
|
||||
clone.querySelectorAll('button, .message-copy-btn, .progress-actions, .progress-footer, .process-details-content').forEach(function (el) {
|
||||
el.remove();
|
||||
});
|
||||
return normalizePreviewText(clone.textContent);
|
||||
}
|
||||
|
||||
/** 每条用户消息开始一轮,直到下一条用户消息前的助手消息都归入该轮。 */
|
||||
function collectConversationTurns() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!messagesEl) return [];
|
||||
const turns = [];
|
||||
let currentTurn = null;
|
||||
Array.from(messagesEl.children).forEach(function (messageEl) {
|
||||
if (!messageEl.classList || !messageEl.classList.contains('message')) return;
|
||||
if (messageEl.classList.contains('user')) {
|
||||
currentTurn = { user: messageEl, assistants: [] };
|
||||
turns.push(currentTurn);
|
||||
return;
|
||||
}
|
||||
if (currentTurn && messageEl.classList.contains('assistant')) {
|
||||
currentTurn.assistants.push(messageEl);
|
||||
}
|
||||
});
|
||||
return turns;
|
||||
}
|
||||
|
||||
function localizedTurnLabel(index, question) {
|
||||
const number = index + 1;
|
||||
const prefix = typeof window.t === 'function'
|
||||
? window.t('chat.turnNumber', { number: number })
|
||||
: '第 ' + number + ' 轮';
|
||||
const safePrefix = prefix && prefix !== 'chat.turnNumber' ? prefix : ('第 ' + number + ' 轮');
|
||||
return question ? safePrefix + ':' + question : safePrefix;
|
||||
}
|
||||
|
||||
function turnPreviewData(turn, index) {
|
||||
const question = trimPreviewText(messagePreviewText(turn && turn.user), 100)
|
||||
|| localizedTurnLabel(index, '');
|
||||
const assistants = turn && turn.assistants ? turn.assistants : [];
|
||||
let assistant = null;
|
||||
for (let i = assistants.length - 1; i >= 0; i--) {
|
||||
if (!assistants[i].classList.contains('progress-message')) {
|
||||
assistant = assistants[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!assistant && assistants.length) assistant = assistants[assistants.length - 1];
|
||||
let summary = trimPreviewText(messagePreviewText(assistant), 220);
|
||||
if (!summary) {
|
||||
summary = typeof window.t === 'function' ? window.t('chat.turnPending') : '正在处理…';
|
||||
if (!summary || summary === 'chat.turnPending') summary = '正在处理…';
|
||||
}
|
||||
return { question: question, summary: summary };
|
||||
}
|
||||
|
||||
function hideTurnPreview() {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
if (preview) preview.hidden = true;
|
||||
}
|
||||
|
||||
function scheduleHideTurnPreview() {
|
||||
if (turnPreviewHideTimer) window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = window.setTimeout(hideTurnPreview, 160);
|
||||
}
|
||||
|
||||
function showTurnPreview(marker, index) {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
const title = document.getElementById('chat-turn-rail-preview-title');
|
||||
const summary = document.getElementById('chat-turn-rail-preview-summary');
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!preview || !title || !summary || !marker || !turn) return;
|
||||
|
||||
const data = turnPreviewData(turn, index);
|
||||
title.textContent = data.question;
|
||||
summary.textContent = data.summary;
|
||||
preview.hidden = false;
|
||||
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const left = Math.min(markerRect.right + 18, window.innerWidth - previewRect.width - 12);
|
||||
const desiredTop = markerRect.top + markerRect.height / 2 - previewRect.height / 2;
|
||||
const top = Math.max(12, Math.min(desiredTop, window.innerHeight - previewRect.height - 12));
|
||||
preview.style.left = Math.max(12, left) + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function setActiveTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!markersEl) return;
|
||||
const markers = Array.from(markersEl.querySelectorAll('.chat-turn-rail-marker'));
|
||||
if (!markers.length) return;
|
||||
const nextIndex = Math.max(0, Math.min(index, markers.length - 1));
|
||||
markers.forEach(function (marker, markerIndex) {
|
||||
const active = markerIndex === nextIndex;
|
||||
marker.classList.toggle('is-active', active);
|
||||
if (active) marker.setAttribute('aria-current', 'step');
|
||||
else marker.removeAttribute('aria-current');
|
||||
});
|
||||
markers[markers.length - 1].classList.toggle('has-pending-new', hasPendingNewBelow);
|
||||
|
||||
if (activeTurnIndex !== nextIndex) {
|
||||
activeTurnIndex = nextIndex;
|
||||
const activeMarker = markers[nextIndex];
|
||||
const markerTop = activeMarker.offsetTop;
|
||||
const markerBottom = markerTop + activeMarker.offsetHeight;
|
||||
if (markerTop < markersEl.scrollTop) {
|
||||
markersEl.scrollTop = Math.max(0, markerTop - 8);
|
||||
} else if (markerBottom > markersEl.scrollTop + markersEl.clientHeight) {
|
||||
markersEl.scrollTop = markerBottom - markersEl.clientHeight + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTurnRailActive() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turns = collectConversationTurns();
|
||||
if (!messagesEl || !turns.length) return;
|
||||
if (isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX)) {
|
||||
setActiveTurnMarker(turns.length - 1);
|
||||
return;
|
||||
}
|
||||
const readingLine = messagesEl.scrollTop + messagesEl.clientHeight * 0.34;
|
||||
let index = 0;
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
if (turns[i].user.offsetTop <= readingLine) index = i;
|
||||
else break;
|
||||
}
|
||||
setActiveTurnMarker(index);
|
||||
}
|
||||
|
||||
function jumpToConversationTurn(index) {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!messagesEl || !turn || !turn.user) return;
|
||||
setScrollDetached();
|
||||
programmaticScroll = true;
|
||||
messagesEl.scrollTo({
|
||||
top: Math.max(0, turn.user.offsetTop - 20),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setActiveTurnMarker(index);
|
||||
hideTurnPreview();
|
||||
window.setTimeout(function () {
|
||||
programmaticScroll = false;
|
||||
lastScrollTop = messagesEl.scrollTop;
|
||||
updateTurnRailActive();
|
||||
}, 420);
|
||||
}
|
||||
|
||||
function focusTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
const marker = markersEl && markersEl.querySelector('.chat-turn-rail-marker[data-turn-index="' + index + '"]');
|
||||
if (marker) marker.focus();
|
||||
}
|
||||
|
||||
function rebuildTurnRail(force) {
|
||||
const rail = getTurnRailEl();
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!rail || !markersEl) return;
|
||||
const turns = collectConversationTurns();
|
||||
rail.hidden = turns.length === 0;
|
||||
if (!turns.length) {
|
||||
markersEl.replaceChildren();
|
||||
turnRailSignature = '';
|
||||
activeTurnIndex = -1;
|
||||
hideTurnPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = turns.map(function (turn, index) {
|
||||
return (turn.user.id || ('turn-' + index)) + ':' + messagePreviewText(turn.user);
|
||||
}).join('|');
|
||||
if (!force && signature === turnRailSignature) {
|
||||
updateTurnRailActive();
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
turns.forEach(function (turn, index) {
|
||||
const marker = document.createElement('button');
|
||||
const question = trimPreviewText(messagePreviewText(turn.user), 88);
|
||||
marker.type = 'button';
|
||||
marker.className = 'chat-turn-rail-marker';
|
||||
marker.dataset.turnIndex = String(index);
|
||||
marker.setAttribute('aria-label', localizedTurnLabel(index, question));
|
||||
marker.addEventListener('click', function () {
|
||||
jumpToConversationTurn(index);
|
||||
});
|
||||
marker.addEventListener('mouseenter', function () {
|
||||
showTurnPreview(marker, index);
|
||||
});
|
||||
marker.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
marker.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.min(turns.length - 1, index + 1));
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.max(0, index - 1));
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(turns.length - 1);
|
||||
}
|
||||
});
|
||||
fragment.appendChild(marker);
|
||||
});
|
||||
markersEl.replaceChildren(fragment);
|
||||
turnRailSignature = signature;
|
||||
activeTurnIndex = -1;
|
||||
updateTurnRailActive();
|
||||
}
|
||||
|
||||
function scheduleTurnRailRefresh(force) {
|
||||
cancelAnimationFrame(turnRailRefreshRaf);
|
||||
turnRailRefreshRaf = requestAnimationFrame(function () {
|
||||
rebuildTurnRail(force === true);
|
||||
});
|
||||
}
|
||||
|
||||
function streamBelongsToVisibleConversation(stream) {
|
||||
if (!stream || !stream.active) return false;
|
||||
const visibleConversationId = typeof window.currentConversationId === 'string'
|
||||
? window.currentConversationId.trim()
|
||||
: '';
|
||||
const streamConversationId = typeof stream.conversationId === 'string'
|
||||
? stream.conversationId.trim()
|
||||
: '';
|
||||
|
||||
// 新建对话在后端返回 conversationId 前,两边都为空,仍属于当前界面。
|
||||
if (!streamConversationId) return !visibleConversationId;
|
||||
return streamConversationId === visibleConversationId;
|
||||
}
|
||||
|
||||
/** 只有当前可见对话的主 POST 流 / task-events 补流才视为「正在输出」 */
|
||||
function isStreamActive() {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
if (live && live.active) return true;
|
||||
if (streamBelongsToVisibleConversation(live)) return true;
|
||||
const replay = window.__csTaskEventStream;
|
||||
return !!(replay && replay.active);
|
||||
return streamBelongsToVisibleConversation(replay);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -51,34 +341,42 @@
|
||||
}
|
||||
|
||||
function isChatMessagesPinnedToBottom() {
|
||||
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
return isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached) */
|
||||
function resumeFollowingIfAtBottom() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
|
||||
if (scrollMode === 'detached') setScrollFollowing();
|
||||
function resumeFollowingIfAtBottom(thresholdPx, userInitiated) {
|
||||
if (!userInitiated && Date.now() < detachLockUntil) return false;
|
||||
const threshold = Number.isFinite(Number(thresholdPx))
|
||||
? Math.max(0, Number(thresholdPx))
|
||||
: CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
|
||||
if (!isNearBottom(threshold)) return false;
|
||||
// detached 是用户明确上滑后的阅读状态。布局变化、流式增高和模式切换
|
||||
// 即使让视口暂时接近底部,也不能自行恢复;只有用户明确向下滚到底才恢复。
|
||||
if (scrollMode === 'detached') {
|
||||
if (!userInitiated) return false;
|
||||
setScrollFollowing();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function captureScrollPinState() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
return scrollMode === 'following';
|
||||
}
|
||||
|
||||
function setScrollFollowing() {
|
||||
scrollMode = 'following';
|
||||
detachLockUntil = 0;
|
||||
userScrollIntentUntil = 0;
|
||||
hasPendingNewBelow = false;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function markPendingNewBelow() {
|
||||
if (scrollMode !== 'detached') return;
|
||||
hasPendingNewBelow = true;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function setScrollDetached() {
|
||||
@@ -88,7 +386,7 @@
|
||||
if (isStreamActive()) {
|
||||
hasPendingNewBelow = true;
|
||||
}
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function scrollChatToBottomInstant() {
|
||||
@@ -98,6 +396,7 @@
|
||||
programmaticScroll = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
});
|
||||
@@ -111,34 +410,52 @@
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
const node = getChatMessagesEl();
|
||||
if (node) lastScrollTop = node.scrollTop;
|
||||
if (node) {
|
||||
lastScrollTop = node.scrollTop;
|
||||
lastScrollHeight = node.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateScrollToBottomFab() {
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (!fab) return;
|
||||
function updateTurnRailState() {
|
||||
updateTurnRailActive();
|
||||
updateReturnLatestButton();
|
||||
}
|
||||
|
||||
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
fab.classList.toggle('visible', show);
|
||||
function updateReturnLatestButton() {
|
||||
const button = getReturnLatestButton();
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!button || !messagesEl) return;
|
||||
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
|
||||
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
const streaming = shouldShow && isStreamActive();
|
||||
button.hidden = !shouldShow;
|
||||
button.classList.toggle('is-streaming', streaming);
|
||||
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
|
||||
}
|
||||
|
||||
let label;
|
||||
if (hasPendingNewBelow) {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottomHasNew')
|
||||
: '↓ 有新内容';
|
||||
} else {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottom')
|
||||
: '回到底部';
|
||||
function isolateReturnLatestPointerEvent(event) {
|
||||
if (!event) return;
|
||||
// 该按钮会在点击后立即隐藏。阻止指针事件继续冒泡,避免长历史对话中
|
||||
// 按钮隐藏与底部审批卡片重排发生在同一帧时产生点击穿透。
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onReturnLatestClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
forceScrollChatToBottom(true);
|
||||
const button = getReturnLatestButton();
|
||||
if (button) {
|
||||
button.hidden = true;
|
||||
button.blur();
|
||||
}
|
||||
fab.setAttribute('aria-label', label);
|
||||
fab.textContent = label;
|
||||
}
|
||||
|
||||
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
if (scrollMode === 'detached') return false;
|
||||
if (wasPinnedBeforeDomUpdate === true) return true;
|
||||
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
|
||||
@@ -153,6 +470,79 @@
|
||||
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
|
||||
}
|
||||
|
||||
/**
|
||||
* 长详情恢复/终态对账会跨多个 requestAnimationFrame 分批增高 DOM。
|
||||
* 单次滚底可能早于最后一批节点;在仍处于 following 时连续若干帧校准,
|
||||
* 用户一旦主动上滑进入 detached,后续帧立即停止,避免抢回阅读位置。
|
||||
*/
|
||||
function settleChatToBottomIfFollowing(frameCount) {
|
||||
const frames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.min(30, Math.floor(Number(frameCount))))
|
||||
: 12;
|
||||
const generation = ++scrollSettleGeneration;
|
||||
|
||||
function settleFrame(remaining) {
|
||||
if (generation !== scrollSettleGeneration) return;
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
scrollChatToBottomInstant();
|
||||
if (remaining > 1) {
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(remaining - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(frames);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
|
||||
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
|
||||
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
|
||||
*/
|
||||
function settleConversationRestoreToBottom(frameCount) {
|
||||
setScrollFollowing();
|
||||
const requestedFrames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.floor(Number(frameCount)))
|
||||
: 30;
|
||||
const minimumDuration = Math.max(
|
||||
CONVERSATION_RESTORE_SETTLE_MIN_MS,
|
||||
Math.ceil(requestedFrames * (1000 / 60))
|
||||
);
|
||||
const generation = ++conversationRestoreGeneration;
|
||||
const startedAt = Date.now();
|
||||
let lastHeight = -1;
|
||||
let stableFrames = 0;
|
||||
|
||||
function settleRestoreFrame() {
|
||||
if (generation !== conversationRestoreGeneration) return;
|
||||
// wheel / touch / keyboard / scrollbar drag 会进入 detached;立即尊重用户阅读位置。
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
scrollChatToBottomInstant();
|
||||
const currentHeight = el.scrollHeight;
|
||||
if (currentHeight === lastHeight && isNearBottom(1)) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
lastHeight = currentHeight;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const reachedStableMinimum = elapsed >= minimumDuration
|
||||
&& stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES;
|
||||
if (!reachedStableMinimum && elapsed < CONVERSATION_RESTORE_SETTLE_MAX_MS) {
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
|
||||
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
|
||||
function scrollChatMessagesToBottomIfPinned(wasPinned) {
|
||||
scheduleChatScrollToBottomIfFollowing(wasPinned);
|
||||
@@ -210,7 +600,8 @@
|
||||
try {
|
||||
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
|
||||
} catch (e) { /* ignore */ }
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
|
||||
@@ -225,7 +616,8 @@
|
||||
} catch (e) { /* ignore */ }
|
||||
markProcessDetailsStreaming(true, assistantDomId);
|
||||
resumeFollowingIfAtBottom();
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function onTaskEventStreamEnd() {
|
||||
@@ -233,6 +625,7 @@
|
||||
}
|
||||
|
||||
function applyMessageScrollOption(options) {
|
||||
scheduleTurnRailRefresh();
|
||||
const opt = (options && options.scroll) || 'follow';
|
||||
if (opt === 'none') return;
|
||||
if (opt === 'force') {
|
||||
@@ -252,22 +645,51 @@
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
const st = el.scrollTop;
|
||||
const sh = el.scrollHeight;
|
||||
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
|
||||
|
||||
if (programmaticScroll) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
|
||||
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
|
||||
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
const st = el.scrollTop;
|
||||
const scrolledUp = st < lastScrollTop - 1;
|
||||
const scrolledDown = st > lastScrollTop + 1;
|
||||
const contentShrank = sh < lastScrollHeight - 1;
|
||||
|
||||
if (scrolledUp) {
|
||||
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
|
||||
// 这不是用户上滑,不应错误退出 following。
|
||||
if (contentShrank) {
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
|
||||
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
|
||||
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
} else if (resumeFollowingIfAtBottom()) {
|
||||
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
|
||||
} else if (
|
||||
scrolledDown &&
|
||||
hasUserScrollIntent &&
|
||||
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
|
||||
) {
|
||||
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
|
||||
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
|
||||
}
|
||||
|
||||
lastScrollTop = st;
|
||||
updateScrollToBottomFab();
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function bindChatScrollListeners() {
|
||||
@@ -276,13 +698,38 @@
|
||||
if (!el) return;
|
||||
listenersBound = true;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
|
||||
el.addEventListener('wheel', function (e) {
|
||||
if (e.deltaY < -1) setScrollDetached();
|
||||
if (Math.abs(e.deltaY) > 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.deltaY < -1) {
|
||||
setScrollDetached();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// 拖动原生纵向滚动条不会产生 wheel;先记录指针意图,再由 scroll 事件确认方向。
|
||||
el.addEventListener('pointerdown', function (e) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientX >= rect.right - 18) {
|
||||
userScrollIntentUntil = Date.now() + 1800;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('keydown', function (e) {
|
||||
const scrollKeys = ['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '];
|
||||
if (scrollKeys.includes(e.key)) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
});
|
||||
|
||||
el.addEventListener('touchmove', function (e) {
|
||||
if (e.touches && e.touches.length === 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
|
||||
if (e.touches[0].clientY > el._csTouchLastY + 4) {
|
||||
setScrollDetached();
|
||||
@@ -301,19 +748,68 @@
|
||||
|
||||
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
|
||||
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (fab) {
|
||||
fab.addEventListener('click', function () {
|
||||
forceScrollChatToBottom(true);
|
||||
});
|
||||
const returnLatestButton = getReturnLatestButton();
|
||||
if (returnLatestButton) {
|
||||
returnLatestButton.addEventListener('pointerdown', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('pointerup', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('click', onReturnLatestClick);
|
||||
}
|
||||
|
||||
const turnPreview = document.getElementById('chat-turn-rail-preview');
|
||||
if (turnPreview) {
|
||||
turnPreview.addEventListener('mouseenter', function () {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
});
|
||||
turnPreview.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
turnRailObserver = new MutationObserver(function () {
|
||||
scheduleTurnRailRefresh();
|
||||
// 最终回复会替换消息气泡内部 HTML,任务详情也会在子树内持续增高。
|
||||
// 只在仍处于 following 时按帧合并粘底;用户上滑后的 detached 状态不受影响。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
}
|
||||
});
|
||||
turnRailObserver.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
chatMessagesResizeObserver = new ResizeObserver(function () {
|
||||
// 顶部运行任务条、输入框或视口变化会改变消息区 clientHeight,
|
||||
// 但不会触发消息子树 MutationObserver。跟随模式下需重新精确粘底。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
});
|
||||
chatMessagesResizeObserver.observe(el);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
hideTurnPreview();
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function initChatScroll() {
|
||||
bindChatScrollListeners();
|
||||
const el = getChatMessagesEl();
|
||||
if (el) lastScrollTop = el.scrollTop;
|
||||
updateScrollToBottomFab();
|
||||
if (el) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
}
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
window.CyberStrikeChatScroll = {
|
||||
@@ -325,6 +821,8 @@
|
||||
captureScrollPinState: captureScrollPinState,
|
||||
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
|
||||
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
|
||||
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
|
||||
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
|
||||
forceScrollToBottom: forceScrollChatToBottom,
|
||||
applyMessageScroll: applyMessageScrollOption,
|
||||
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
|
||||
@@ -333,6 +831,8 @@
|
||||
markProcessDetailsStreaming: markProcessDetailsStreaming,
|
||||
setScrollFollowing: setScrollFollowing,
|
||||
setScrollDetached: setScrollDetached,
|
||||
refreshReturnLatest: updateReturnLatestButton,
|
||||
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
|
||||
};
|
||||
|
||||
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
|
||||
|
||||
+1665
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('全局置顶检查接口结果并即时通知项目文件夹', () => {
|
||||
const source = functionSource(chat, 'pinConversation', 'showMoveToGroupSubmenu');
|
||||
|
||||
assert.match(source, /assertConversationActionResponse\(updateResponse, '更新置顶状态失败'\)/);
|
||||
assert.match(source, /notifyConversationPinnedChanged\(convId, newPinned\)/);
|
||||
assert.match(source, /loadConversationsWithGroups\(\)/);
|
||||
});
|
||||
|
||||
test('项目文件夹内置顶对话优先排序并显示图钉', () => {
|
||||
const sortSource = functionSource(projects, 'sortProjectFolderConversations', 'updateChatProjectConversationPinnedState');
|
||||
const itemSource = functionSource(projects, 'appendChatProjectConversationItem', 'selectChatProjectConversationItem');
|
||||
|
||||
assert.match(sortSource, /Number\(!!b\?\.pinned\) - Number\(!!a\?\.pinned\)/);
|
||||
assert.match(itemSource, /if \(conversation\.pinned\)/);
|
||||
assert.match(itemSource, /project-conversation-pinned/);
|
||||
});
|
||||
|
||||
test('删除事件立即移除项目缓存并触发权威刷新', () => {
|
||||
const removeSource = functionSource(projects, 'removeChatProjectConversation', 'refreshChatProjectFoldersAfterAction');
|
||||
|
||||
assert.match(removeSource, /chatProjectFolderContext\.conversations = chatProjectFolderContext\.conversations\.filter/);
|
||||
assert.match(projects, /document\.addEventListener\('conversation-deleted',[\s\S]{0,300}removeChatProjectConversation\(conversationId\)[\s\S]{0,180}refreshChatProjectFoldersAfterAction\(\)/);
|
||||
assert.match(chat, /document\.dispatchEvent\(new CustomEvent\('conversation-deleted'/);
|
||||
});
|
||||
|
||||
test('较旧的项目文件夹请求不能覆盖较新的操作结果', () => {
|
||||
const source = functionSource(projects, 'loadChatProjectFolderContext', 'getProjectConversationSortTime');
|
||||
|
||||
assert.match(source, /const loadSeq = \+\+chatProjectFolderContextLoadSeq/);
|
||||
assert.match(source, /if \(loadSeq !== chatProjectFolderContextLoadSeq\) return false/);
|
||||
});
|
||||
|
||||
test('项目文件夹菜单可以置顶并立即更新排序', () => {
|
||||
const toggleSource = functionSource(projects, 'toggleProjectPinnedFromListMenu', 'initProjectListActionMenu');
|
||||
const folderSource = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(template, /onclick="toggleProjectPinnedFromListMenu\(\)"/);
|
||||
assert.match(toggleSource, /JSON\.stringify\(\{ pinned: nextPinned \}\)/);
|
||||
assert.match(toggleSource, /updateCachedProjectPinnedState\(projectId, nextPinned\)/);
|
||||
assert.match(folderSource, /if \(!isUnassigned && project\.pinned\)/);
|
||||
assert.match(folderSource, /project-folder-pinned/);
|
||||
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
|
||||
});
|
||||
|
||||
test('对话侧栏不再显示对话分组区域', () => {
|
||||
assert.doesNotMatch(template, /class="conversation-groups-section"/);
|
||||
assert.doesNotMatch(template, /id="conversation-groups-list"/);
|
||||
});
|
||||
|
||||
test('删除对话分组检查接口结果并先清理本地状态', () => {
|
||||
const deleteSource = functionSource(chat, 'deleteConversationGroupById', 'deleteGroup');
|
||||
const contextSource = functionSource(chat, 'deleteGroupFromContext', 'closeGroupContextMenu');
|
||||
|
||||
assert.match(deleteSource, /assertConversationActionResponse\(deleteResponse, '删除分组失败'\)/);
|
||||
assert.match(deleteSource, /removeConversationGroupFromLocalState\(groupId\)/);
|
||||
assert.match(deleteSource, /if \(currentGroupId === groupId\) exitGroupDetail\(\)/);
|
||||
assert.match(contextSource, /deleteConversationGroupById\(groupId, \{ closeContextMenu: true \}\)/);
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chatScroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
const handler = fs.readFileSync('internal/handler/hitl.go', 'utf8');
|
||||
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
||||
|
||||
test('输入区提供独立审批入口并暴露可配置等待时限', () => {
|
||||
assert.match(template, /id="chat-hitl-approval-dock"/);
|
||||
assert.match(template, /id="hitl-timeout-select"/);
|
||||
assert.match(template, /option value="300" selected/);
|
||||
assert.match(chat, /DEFAULT_HITL_TIMEOUT_SECONDS = 300/);
|
||||
assert.match(chat, /timeoutSeconds: normalizeHitlTimeoutForChat/);
|
||||
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
|
||||
});
|
||||
|
||||
test('输入框可直接保存系统模型和系统推理强度且审批模型只出现在审计 Agent 入口', () => {
|
||||
assert.match(chat, /function currentSystemModelLabel\(\)/);
|
||||
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
|
||||
assert.match(chat, /function currentHitlAuditModelLabel\(\)/);
|
||||
assert.match(chat, /const label = currentSystemModelLabel\(\)/);
|
||||
assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/);
|
||||
assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/);
|
||||
assert.match(chat, /hitlAuditModel\.model\.trim\(\)/);
|
||||
assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/);
|
||||
assert.match(template, /id="chat-system-model-menu"[^>]+hidden/);
|
||||
assert.doesNotMatch(template, /id="chat-reasoning-shortcut"/);
|
||||
assert.match(template, /openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/);
|
||||
assert.match(chat, /function renderChatReasoningEffortOptions\(\)/);
|
||||
assert.match(chat, /function currentSystemReasoningEffort\(\)[\s\S]{0,500}reasoning\.effort/);
|
||||
assert.match(chat, /case 'low': return 'low'[\s\S]{0,300}case 'max': return 'max'/);
|
||||
assert.match(chat, /chatTranslate\('chat\.reasoningEffortUnset', '不指定'\)/);
|
||||
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,2400}reasoning: \{ \.\.\.\(state\.channel\.reasoning \|\| \{\}\), effort: chosen \}/);
|
||||
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,4200}body: JSON\.stringify\(\{ ai: state\.ai \}\)[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
|
||||
assert.match(chat, /function openChatSystemModelPicker\(event\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/);
|
||||
assert.match(chat, /function selectChatSystemModel\(model\)[\s\S]{0,2600}method: 'PUT'[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
|
||||
assert.match(chat, /body: JSON\.stringify\(\{ ai: state\.ai \}\)/);
|
||||
assert.equal(zh.chat.modelSettingsAria, '选择模型与推理强度');
|
||||
assert.equal(en.chat.modelSettingsAria, 'Choose model and reasoning effort');
|
||||
});
|
||||
|
||||
test('审批请求按浏览器、命令、文件和通用工具动态描述', () => {
|
||||
assert.match(monitor, /function hitlApprovalTemplate/);
|
||||
assert.match(monitor, /hitlApprovalTranslate\(key, fallback\)/);
|
||||
assert.match(monitor, /replaceAll\('\{\{' \+ name \+ '\}\}'/);
|
||||
assert.match(monitor, /function describeHitlApprovalRequest/);
|
||||
assert.match(monitor, /requestVisitUrl/);
|
||||
assert.match(monitor, /requestCommand/);
|
||||
assert.match(monitor, /requestFile/);
|
||||
assert.match(monitor, /requestGeneric/);
|
||||
assert.match(monitor, /let displayTool = rawToolName/);
|
||||
assert.doesNotMatch(monitor, /displayTool = 'Browser'/);
|
||||
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolTerminal'/);
|
||||
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolFiles'/);
|
||||
});
|
||||
|
||||
test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () => {
|
||||
const logsHandler = fs.readFileSync('internal/handler/hitl_logs.go', 'utf8');
|
||||
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
|
||||
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
|
||||
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
|
||||
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
|
||||
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
|
||||
assert.match(monitor, /if \(!isAgentReviewedHitl\(data\)\) \{[\s\S]{0,240}bindHitlApprovalCountdown/);
|
||||
assert.match(monitor, /if \(isAgentReviewedHitl\(data\)\) return false/);
|
||||
assert.match(projects, /filter\(isHumanProjectPendingApproval\)/);
|
||||
assert.match(projects, /if \(!isHumanProjectPendingApproval\(details\)\) return/);
|
||||
assert.match(hitlPage, /const items = rawItems\.filter/);
|
||||
});
|
||||
|
||||
test('人工批准不要求输入备注,审查编辑仅发送真正修改过的参数', () => {
|
||||
assert.match(monitor, /if \(!approveBtn \|\| !rejectBtn \|\| !statusEl\) return/);
|
||||
assert.doesNotMatch(monitor, /!commentInput \|\| !statusEl/);
|
||||
assert.match(monitor, /JSON\.stringify\(editedArgs\) === JSON\.stringify\(originalArgs\)/);
|
||||
assert.match(monitor, /editedArgs = null/);
|
||||
});
|
||||
|
||||
test('长历史对话的回到最新按钮不会把滚动点击穿透到审批操作', () => {
|
||||
assert.match(chatScroll, /function isolateReturnLatestPointerEvent\(event\)/);
|
||||
assert.match(chatScroll, /returnLatestButton\.addEventListener\('pointerdown', isolateReturnLatestPointerEvent\)/);
|
||||
assert.match(chatScroll, /function onReturnLatestClick\(event\)[\s\S]{0,260}event\.preventDefault\(\)[\s\S]{0,180}event\.stopPropagation\(\)/);
|
||||
assert.match(monitor, /const bindExplicitHitlAction = function \(button, decision\)/);
|
||||
assert.match(monitor, /button\.addEventListener\('pointerdown'[\s\S]{0,900}pointerClick && !explicitlyPressed/);
|
||||
assert.match(monitor, /bindExplicitHitlAction\(approveBtn, 'approve'\)/);
|
||||
assert.match(monitor, /bindExplicitHitlAction\(rejectBtn, 'reject'\)/);
|
||||
});
|
||||
|
||||
test('轮次导航使用连续大热区并允许鼠标平滑进入 Codex 风格预览卡', () => {
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,260}gap: 0;/);
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,420}overflow-x: hidden;/);
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,520}touch-action: pan-y;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker \{[\s\S]{0,260}width: 36px;[\s\S]{0,160}height: 11px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker::before \{[\s\S]{0,420}width: 12px;[\s\S]{0,120}height: 3px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker:hover::before \{[\s\S]{0,100}width: 22px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-preview \{[\s\S]{0,520}pointer-events: auto;/);
|
||||
assert.match(chatScroll, /function scheduleHideTurnPreview\(\)/);
|
||||
assert.match(chatScroll, /window\.setTimeout\(hideTurnPreview, 160\)/);
|
||||
assert.match(chatScroll, /turnPreview\.addEventListener\('mouseenter'/);
|
||||
assert.match(chatScroll, /marker\.addEventListener\('mouseleave', scheduleHideTurnPreview\)/);
|
||||
});
|
||||
|
||||
test('倒计时由服务端时间驱动,到期时只锁定界面并等待服务端拒绝', () => {
|
||||
assert.match(handler, /payload\["hitlApproval"\]/);
|
||||
assert.match(handler, /"expiresAt":\s+approvalExpiresAt/);
|
||||
assert.match(handler, /status = "timeout"/);
|
||||
assert.match(handler, /decidedBy = "system"/);
|
||||
assert.match(monitor, /function bindHitlApprovalCountdown/);
|
||||
assert.match(monitor, /setInterval\(update, 250\)/);
|
||||
assert.match(monitor, /expiredAutoRejected/);
|
||||
assert.doesNotMatch(monitor, /remaining <= 0[\s\S]{0,240}submitHitlDecisionWithPayload/);
|
||||
});
|
||||
|
||||
test('项目对话列表能同时显示等待批准与运行状态', () => {
|
||||
assert.match(projects, /pendingApprovalByConversation: new Map/);
|
||||
assert.match(projects, /statusKinds\.push\('approval'\)/);
|
||||
assert.match(projects, /statusKinds\.push\('running'\)/);
|
||||
assert.match(projects, /window\.setProjectConversationApprovalStatus/);
|
||||
assert.match(projects, /api\/hitl\/pending\?page=1&pageSize=200/);
|
||||
assert.match(projects, /function bindProjectApprovalProgress/);
|
||||
assert.match(projects, /project-approval-progress-value/);
|
||||
assert.match(projects, /PROJECT_APPROVAL_TICK_INTERVAL_MS = 1000/);
|
||||
assert.match(projects, /function registerProjectApprovalTicker/);
|
||||
assert.match(monitor, /function renderDirectHitlSidebarApproval/);
|
||||
assert.match(monitor, /hitlSidebarApprovalSyncTimer = window\.setInterval/);
|
||||
});
|
||||
|
||||
test('项目文件夹汇总始终为绿色且只有具体对话按剩余时间变色', () => {
|
||||
assert.match(projects, /waitingApprovalCount/);
|
||||
assert.match(projects, /aggregate: true, count: folderApprovals\.length/);
|
||||
assert.match(projects, /project-task-status--approval-summary', 'is-urgency-normal'/);
|
||||
assert.match(projects, /status\.dataset\.approvalUrgency = 'normal'/);
|
||||
assert.match(projects, /if \(isApprovalSummary\)[\s\S]{0,520}else \{[\s\S]{0,160}bindProjectApprovalUrgency\(status, details, label\)/);
|
||||
assert.doesNotMatch(projects, /currentExpiry < earliestExpiry/);
|
||||
assert.match(projects, /PROJECT_APPROVAL_URGENCY_CLASSES/);
|
||||
assert.match(projects, /remaining <= 60 \* 1000/);
|
||||
assert.match(projects, /remaining <= 3 \* 60 \* 1000/);
|
||||
assert.doesNotMatch(projects, /remaining <= 5 \* 60 \* 1000/);
|
||||
assert.match(projects, /project-task-status--approval-summary/);
|
||||
assert.equal(zh.hitl.waitingApprovalCount, '等待批准 {{count}}');
|
||||
assert.equal(zh.hitl.approvalUrgencyMoreThanThree, '最早审批将在 3 分钟后到期');
|
||||
assert.equal(typeof en.hitl.waitingApprovalCount, 'string');
|
||||
const urgencyFunctionSource = projects.match(
|
||||
/function projectApprovalUrgencyLevel\(remainingMilliseconds, hasDeadline\) \{[\s\S]*?\n\}/
|
||||
);
|
||||
assert.ok(urgencyFunctionSource, '应提供可测试的审批紧急程度函数');
|
||||
const urgencyLevel = vm.runInNewContext(`(${urgencyFunctionSource[0]})`);
|
||||
assert.equal(urgencyLevel(6 * 60 * 1000, true), 'normal');
|
||||
assert.equal(urgencyLevel(4 * 60 * 1000, true), 'normal');
|
||||
assert.equal(urgencyLevel(3 * 60 * 1000 + 1, true), 'normal');
|
||||
assert.equal(urgencyLevel(3 * 60 * 1000, true), 'warning');
|
||||
assert.equal(urgencyLevel(2 * 60 * 1000, true), 'warning');
|
||||
assert.equal(urgencyLevel(30 * 1000, true), 'critical');
|
||||
assert.equal(urgencyLevel(0, false), 'normal');
|
||||
});
|
||||
|
||||
test('切换对话后主按钮只读取当前可见对话的运行状态', () => {
|
||||
assert.match(chat, /function getVisibleChatConversationId\(\)/);
|
||||
assert.match(chat, /function shouldTreatLiveChatTaskAsCurrent\(/);
|
||||
assert.match(chat, /function isLiveChatTaskVisible\(/);
|
||||
assert.match(chat, /if \(visibleConversationId\) return visibleConversationId/);
|
||||
assert.match(chat, /isConversationTaskRunning\(visibleConversationId\)/);
|
||||
assert.doesNotMatch(
|
||||
chat,
|
||||
/function getCurrentChatTaskConversationId\(\) \{[\s\S]{0,220}if \(live && live\.active && live\.conversationId\) \{[\s\S]{0,100}return String\(live\.conversationId\)/
|
||||
);
|
||||
const visibilityFunctionSource = chat.match(
|
||||
/function shouldTreatLiveChatTaskAsCurrent\(liveConversationId, visibleConversationId, hasVisibleProgress\) \{[\s\S]*?\n\}/
|
||||
);
|
||||
assert.ok(visibilityFunctionSource, '应提供可测试的当前任务隔离函数');
|
||||
const isCurrent = vm.runInNewContext(`(${visibilityFunctionSource[0]})`);
|
||||
assert.equal(isCurrent('running-conversation', '', true), false);
|
||||
assert.equal(isCurrent('running-conversation', 'new-conversation', true), false);
|
||||
assert.equal(isCurrent('running-conversation', 'running-conversation', false), true);
|
||||
assert.equal(isCurrent('', '', true), true);
|
||||
assert.equal(isCurrent('', '', false), false);
|
||||
});
|
||||
|
||||
test('无项目使用独立虚拟文件夹且顶部新任务继承当前项目', () => {
|
||||
assert.match(projects, /CHAT_UNASSIGNED_PROJECT_FOLDER_ID/);
|
||||
assert.match(projects, /_isUnassigned: true/);
|
||||
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
|
||||
assert.match(projects, /window\.startNewConversation\(\{ projectId: isUnassigned \? '' : project\.id \}\)/);
|
||||
assert.match(chat, /Object\.prototype\.hasOwnProperty\.call\(options, 'projectId'\)/);
|
||||
assert.match(chat, /typeof resolveChatProjectSelection === 'function'/);
|
||||
assert.match(chat, /String\(inheritedProjectId \|\| ''\)\.trim\(\)/);
|
||||
assert.match(chat, /typeof setActiveProjectId === 'function'\) setActiveProjectId\(requestedProjectId\)/);
|
||||
assert.equal(zh.chat.newUnassignedConversation, '新建无项目对话');
|
||||
assert.equal(typeof en.chat.newUnassignedConversation, 'string');
|
||||
});
|
||||
|
||||
test('单个对话的审批徽标随倒计时同步切换紧急颜色', () => {
|
||||
assert.match(projects, /bindProjectApprovalProgress\(status, details\);\s*bindProjectApprovalUrgency\(status, details, label\);/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.project-task-status--approval\.is-urgency-critical/);
|
||||
});
|
||||
|
||||
test('项目状态刷新复用单一计时器且切换对话不重复请求完整项目上下文', () => {
|
||||
assert.match(projects, /const projectApprovalTickerEntries = new Set\(\)/);
|
||||
assert.match(projects, /if \(!changed && !approvalChanged\) return/);
|
||||
assert.match(projects, /options\.reloadFolders !== false/);
|
||||
assert.match(chat, /refreshChatProjectSelector\(\{ reloadFolders: false, renderFolders: false \}\)/);
|
||||
assert.match(projects, /function selectChatProjectConversationItem/);
|
||||
assert.match(projects, /options\.renderFolders !== false/);
|
||||
assert.match(projects, /projectConversationPreviewSuppressedUntil = Date\.now\(\) \+ 700/);
|
||||
assert.match(projects, /project-task-status-group--folder/);
|
||||
assert.doesNotMatch(fs.readFileSync('web/static/css/style.css', 'utf8'), /project-task-status-group--folder \.project-task-status--running/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.active-tasks-bar \{[\s\S]*?padding: 13px 24px 14px;/);
|
||||
});
|
||||
|
||||
test('运行中对话切换会取消旧事件流并仅恢复最新一页过程详情', () => {
|
||||
assert.match(chat, /window\.cancelRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /function cancelRunningTaskEventStream/);
|
||||
assert.match(monitor, /abortController\.abort\(\)/);
|
||||
assert.match(monitor, /signal: abortController\.signal/);
|
||||
assert.match(monitor, /initialLatest: true/);
|
||||
assert.match(monitor, /autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状态', () => {
|
||||
assert.match(chat, /function ownsLiveChatStream\(liveStream\)/);
|
||||
assert.match(chat, /function clearLiveChatStreamIfOwned\(liveStream\)/);
|
||||
assert.match(chat, /function detachLiveChatStreamForNavigation\(nextConversationId, force = false\)/);
|
||||
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
|
||||
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
|
||||
assert.match(chat, /signal: requestAbortController\.signal/);
|
||||
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
|
||||
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
|
||||
assert.match(chat, /window\.clearChatHitlApprovalDock\(\)/);
|
||||
assert.match(monitor, /if \(conversationId && conversationId !== currentId\) return false/);
|
||||
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
|
||||
assert.match(monitor, /timeline\.scrollTop = targetTop/);
|
||||
assert.match(chat, /let loadConversationAbortController = null/);
|
||||
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
|
||||
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-4/);
|
||||
assert.match(template, /style\.css\?v=20260813-6/);
|
||||
});
|
||||
|
||||
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?display: block/);
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?padding-block: 1px/);
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?line-height: 1\.4/);
|
||||
});
|
||||
|
||||
test('任务结束后对话内审批按钮会变灰并禁止继续操作', () => {
|
||||
assert.match(monitor, /ready: false/);
|
||||
assert.match(monitor, /function setHitlApprovalTaskAvailability/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(id\)/);
|
||||
assert.match(monitor, /hitlPendingInterruptTracker\.ready/);
|
||||
assert.match(monitor, /!hitlPendingInterruptTracker\.has\(interruptId\)/);
|
||||
assert.match(monitor, /button\.disabled = true/);
|
||||
assert.match(monitor, /function setHitlApprovalInterruptedVisualState/);
|
||||
assert.match(monitor, /stopHitlApprovalCountdown\(panel\)/);
|
||||
assert.match(monitor, /removeAttribute\('data-hitl-expires-at'\)/);
|
||||
assert.match(monitor, /hitl\.interruptedApprovalCancelled/);
|
||||
assert.match(monitor, /reconcileHitlApprovalStateWithActiveTasks\(normalizedTasks\)/);
|
||||
assert.match(monitor, /syncHitlApprovalTaskAvailability\(\)/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /hitl-approval-task-closed/);
|
||||
assert.equal(zh.hitl.taskClosedApprovalUnavailable, '任务已结束,审批不可用');
|
||||
assert.equal(zh.hitl.interruptedApprovalCancelled, '任务已中断,审批已取消');
|
||||
assert.equal(typeof en.hitl.taskClosedApprovalUnavailable, 'string');
|
||||
assert.equal(typeof en.hitl.interruptedApprovalCancelled, 'string');
|
||||
});
|
||||
|
||||
test('项目树只保留当前进程仍在运行任务的审批状态', () => {
|
||||
assert.match(projects, /chatProjectFolderContext\.runningIds\.has\(conversationId\)/);
|
||||
assert.match(projects, /pendingApprovalByConversation\.delete\(conversationId\)/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(conversationId\)/);
|
||||
});
|
||||
|
||||
test('审批状态主动轮询并在服务不可用时立即关闭旧审批', () => {
|
||||
assert.match(monitor, /ACTIVE_TASK_REFRESH_INTERVAL = 2000/);
|
||||
assert.match(monitor, /apiFetch\('\/api\/hitl\/pending\?page=1&pageSize=200'\)/);
|
||||
assert.match(monitor, /function reconcilePendingHitlState\(rawItems\)/);
|
||||
assert.match(monitor, /renderChatHitlApprovalDock\(currentPending\)/);
|
||||
assert.match(monitor, /restoreHitlInlineForConversation\(currentId\)/);
|
||||
assert.match(monitor, /case 'conversation':[\s\S]{0,1800}window\.refreshChatProjectFolders\(\)/);
|
||||
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
|
||||
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
|
||||
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
|
||||
assert.match(template, /projects\.js\?v=20260812-6/);
|
||||
});
|
||||
|
||||
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /shouldMigrateLegacyHitlTimeout/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /timeoutSeconds: 300/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /markLegacyHitlTimeoutMigrated/);
|
||||
});
|
||||
|
||||
test('审批体验文案具有完整中英文资源', () => {
|
||||
const hitlKeys = [
|
||||
'waitingApprovalShort',
|
||||
'requestVisitUrl',
|
||||
'requestCommand',
|
||||
'viewRequestDetails',
|
||||
'timeoutAutoReject',
|
||||
'expiredRejected',
|
||||
];
|
||||
const chatKeys = [
|
||||
'hitlTimeoutLabel',
|
||||
'hitlTimeoutFiveMinutes',
|
||||
'hitlTimeoutUnlimited',
|
||||
'hitlTimeoutHint',
|
||||
];
|
||||
hitlKeys.forEach((key) => {
|
||||
assert.equal(typeof zh.hitl[key], 'string');
|
||||
assert.equal(typeof en.hitl[key], 'string');
|
||||
});
|
||||
chatKeys.forEach((key) => {
|
||||
assert.equal(typeof zh.chat[key], 'string');
|
||||
assert.equal(typeof en.chat[key], 'string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const hitl = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('已有会话缺少本地配置时不会继承其他会话的最近审批设置', () => {
|
||||
const source = functionSource(chat, 'getHitlConfigForConversation', 'setHitlReviewerUI');
|
||||
const existingConversationBranch = source.slice(source.indexOf('const key = getHitlStorageKeyByConversation(cid)'));
|
||||
|
||||
assert.doesNotMatch(existingConversationBranch, /getHitlLastGlobalConfig/);
|
||||
assert.match(existingConversationBranch, /if \(!raw\) \{\s*return fallback;/);
|
||||
assert.match(existingConversationBranch, /catch \(e\) \{\s*return fallback;/);
|
||||
});
|
||||
|
||||
test('服务端默认审批人只更新默认值,不覆盖最近会话选择', () => {
|
||||
const source = functionSource(hitl, 'applyHitlDefaultReviewerFromServer', 'fetchHitlDefaultReviewer');
|
||||
|
||||
assert.match(source, /window\.csaiHitlDefaultReviewer = v/);
|
||||
assert.doesNotMatch(source, /saveHitlLastGlobalConfig/);
|
||||
});
|
||||
|
||||
test('恢复会话审批配置时保留该会话自己的审批人', () => {
|
||||
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
|
||||
|
||||
assert.match(source, /const localReviewer = hitlReviewerNormalize\(local && local\.reviewer\)/);
|
||||
assert.match(source, /merged = \{[\s\S]*?reviewer: localReviewer/);
|
||||
assert.match(source, /saveHitlConversationConfig\(conversationId, \{[\s\S]*?reviewer: localReviewer/);
|
||||
assert.doesNotMatch(source, /getHitlLastGlobalConfig/);
|
||||
});
|
||||
|
||||
test('异步同步只能刷新仍处于当前会话的审批界面', () => {
|
||||
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
|
||||
|
||||
assert.match(source, /getCurrentConversationIdForHitl\(\) === conversationId[\s\S]*?window\.applyHitlConfigToUI\(normalizedCfg\)/);
|
||||
});
|
||||
+48
-38
@@ -98,6 +98,7 @@ function hitlT(key, fallback, params) {
|
||||
|
||||
const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
|
||||
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
|
||||
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
|
||||
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function hitlPaginationT(key, opts, fallback) {
|
||||
@@ -212,6 +213,21 @@ function normalizeHitlTimeoutSeconds(v, fallback) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function shouldMigrateLegacyHitlTimeout(conversationId, timeoutSeconds) {
|
||||
if (!conversationId || normalizeHitlTimeoutSeconds(timeoutSeconds, 0) > 0) return false;
|
||||
try {
|
||||
return localStorage.getItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId) !== '1';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLegacyHitlTimeoutMigrated(conversationId) {
|
||||
try {
|
||||
localStorage.setItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId, '1');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getCurrentConversationIdForHitl() {
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
|
||||
return window.currentConversationId;
|
||||
@@ -241,16 +257,6 @@ function applyHitlDefaultReviewerFromServer(reviewer) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlDefaultReviewer = v;
|
||||
}
|
||||
if (typeof window.saveHitlLastGlobalConfig === 'function' && typeof window.getHitlLastGlobalConfig === 'function') {
|
||||
const gl = window.getHitlLastGlobalConfig();
|
||||
const base = gl && typeof gl === 'object'
|
||||
? gl
|
||||
: { mode: 'off', sensitiveTools: '', updatedAt: '' };
|
||||
window.saveHitlLastGlobalConfig(Object.assign({}, base, {
|
||||
reviewer: v,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -352,9 +358,9 @@ function showHitlPageWhitelistFeedback(text, isError) {
|
||||
el.className = 'hitl-apply-feedback' + (isError ? ' hitl-apply-feedback--error' : '');
|
||||
}
|
||||
|
||||
function syncHitlSidebarWhitelistDisplay(toolsStr) {
|
||||
const sidebarEl = document.getElementById('hitl-sensitive-tools');
|
||||
if (sidebarEl) sidebarEl.value = toolsStr;
|
||||
function syncHitlSidebarWhitelistDisplay(_toolsStr) {
|
||||
// The chat field is conversation-scoped. Updating the global allowlist page
|
||||
// must not replace it with a merged global + conversation display value.
|
||||
}
|
||||
|
||||
async function fetchHitlGlobalToolWhitelist() {
|
||||
@@ -534,45 +540,41 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
const local = readHitlLocalStorageConv(conversationId);
|
||||
const localMode = local && local.mode ? hitlModeNormalize(local.mode) : 'off';
|
||||
if (localMode !== 'off') {
|
||||
const localReviewer = hitlReviewerNormalize(local && local.reviewer);
|
||||
let localToolsStr = typeof local.sensitiveTools === 'string' ? local.sensitiveTools : '';
|
||||
localToolsStr = strip(globalWL, localToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: localMode,
|
||||
reviewer: localReviewer,
|
||||
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(
|
||||
local && local.timeoutSeconds,
|
||||
normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: localMode,
|
||||
reviewer: localReviewer,
|
||||
sensitiveTools: localToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
} else {
|
||||
const gl = typeof window.getHitlLastGlobalConfig === 'function' ? window.getHitlLastGlobalConfig() : null;
|
||||
const glMode = gl && gl.mode ? hitlModeNormalize(gl.mode) : 'off';
|
||||
if (glMode !== 'off') {
|
||||
let glToolsStr = typeof gl.sensitiveTools === 'string' ? gl.sensitiveTools : '';
|
||||
glToolsStr = strip(globalWL, glToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldMigrateLegacyHitlTimeout(conversationId, merged.timeoutSeconds)) {
|
||||
merged = Object.assign({}, merged, { timeoutSeconds: 300 });
|
||||
try {
|
||||
await saveHitlConversationConfig(conversationId, merged);
|
||||
markLegacyHitlTimeoutMigrated(conversationId);
|
||||
} catch (err) {
|
||||
console.warn('HITL 旧会话等待时限迁移失败,将在下次加载时重试:', err);
|
||||
}
|
||||
} else if (normalizeHitlTimeoutSeconds(merged.timeoutSeconds, 0) > 0) {
|
||||
markLegacyHitlTimeoutMigrated(conversationId);
|
||||
}
|
||||
const uiMode = hitlEffectiveEnabled(merged) ? hitlModeNormalize(merged.mode) : 'off';
|
||||
const rawArr = Array.isArray(merged.sensitiveTools)
|
||||
? merged.sensitiveTools
|
||||
@@ -590,7 +592,10 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
localStorage.setItem('chat_hitl_config_' + conversationId, JSON.stringify(normalizedCfg));
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof window.applyHitlConfigToUI === 'function') {
|
||||
if (
|
||||
getCurrentConversationIdForHitl() === conversationId &&
|
||||
typeof window.applyHitlConfigToUI === 'function'
|
||||
) {
|
||||
window.applyHitlConfigToUI(normalizedCfg);
|
||||
}
|
||||
reconcileHitlUiState();
|
||||
@@ -835,7 +840,11 @@ async function refreshHitlPending() {
|
||||
throw new Error('request failed');
|
||||
}
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const rawItems = Array.isArray(data.items) ? data.items : [];
|
||||
const items = rawItems.filter(function (item) {
|
||||
return hitlReviewerNormalize(item && (item.reviewer || item.decidedBy || item.decided_by)) !== 'audit_agent' &&
|
||||
String(item && item.status || '').trim().toLowerCase() !== 'audit_running';
|
||||
});
|
||||
let workflowRuns = [];
|
||||
try {
|
||||
const wfResp = await hitlApiFetch('/api/workflows/runs/pending', { credentials: 'same-origin' });
|
||||
@@ -856,7 +865,8 @@ async function refreshHitlPending() {
|
||||
return conv.indexOf(searchQ) >= 0 || wfId.indexOf(searchQ) >= 0 || runId.indexOf(searchQ) >= 0 || label.indexOf(searchQ) >= 0;
|
||||
});
|
||||
}
|
||||
hitlPendingTotal = (typeof data.total === 'number' ? data.total : items.length) + workflowRuns.length;
|
||||
const hiddenAgentItems = rawItems.length - items.length;
|
||||
hitlPendingTotal = Math.max(0, (typeof data.total === 'number' ? data.total : rawItems.length) - hiddenAgentItems) + workflowRuns.length;
|
||||
const maxPage = Math.max(1, Math.ceil(hitlPendingTotal / hitlPendingPageSize));
|
||||
if (hitlPendingPage > maxPage) {
|
||||
hitlPendingPage = maxPage;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('主代理迭代节点获得可访问的分割线语义', () => {
|
||||
const source = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(source, /if \(type === 'iteration'\)/);
|
||||
assert.match(source, /if \(scope !== 'sub'\)/);
|
||||
assert.match(source, /classList\.add\('timeline-iteration-divider'\)/);
|
||||
assert.match(source, /setAttribute\('role', 'separator'\)/);
|
||||
assert.match(source, /setAttribute\('aria-label', String\(options\.title \|\| ''\)\)/);
|
||||
});
|
||||
|
||||
test('迭代分割线只在主对话时间线中使用轻量渐变横线', () => {
|
||||
assert.match(styles, /\.timeline-item-iteration\.timeline-iteration-divider::after/);
|
||||
assert.match(styles, /linear-gradient\(/);
|
||||
assert.match(styles, /color-mix\(in srgb, var\(--border-color\) 88%, transparent\)/);
|
||||
assert.match(styles, /@media \(max-width: 768px\)/);
|
||||
});
|
||||
+1726
-238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
const rbac = fs.readFileSync('web/static/js/rbac-guards.js', 'utf8');
|
||||
const zh = fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8');
|
||||
const en = fs.readFileSync('web/static/i18n/en-US.json', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
|
||||
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(source, /row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/);
|
||||
assert.match(source, /button\.addEventListener\('focus', \(\) => scheduleShowProjectFolderPreview/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(!isUnassigned\) \{\s*row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/
|
||||
);
|
||||
});
|
||||
|
||||
test('无项目预览隐藏测试范围和编辑入口', () => {
|
||||
const source = functionSource(projects, 'showProjectFolderPreview', 'scheduleShowProjectFolderPreview');
|
||||
|
||||
assert.match(source, /preview\.classList\.toggle\('is-unassigned', isUnassigned\)/);
|
||||
assert.match(source, /scopeRow\.hidden = isUnassigned \|\| !scope/);
|
||||
assert.match(source, /editButton\.hidden = isUnassigned/);
|
||||
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-edit\s*\{\s*display: none !important;/);
|
||||
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-details\s*\{\s*border-bottom: 0;/);
|
||||
});
|
||||
|
||||
test('项目标题提供受权限保护的新建项目入口', () => {
|
||||
const source = functionSource(projects, 'showNewProjectModalFromChatSidebar', 'saveProjectModal');
|
||||
|
||||
assert.match(html, /class="add-group-btn project-folders-add-btn"[\s\S]*?onclick="showNewProjectModalFromChatSidebar\(\)"/);
|
||||
assert.match(chat, /projectHeader\.querySelector\('\.project-folders-add-btn'\)/);
|
||||
assert.match(source, /window\._projectModalFromChat = false/);
|
||||
assert.match(source, /window\._projectModalFromChatSidebar = true/);
|
||||
assert.match(rbac, /showNewProjectModalFromChatSidebar: 'project:write'/);
|
||||
});
|
||||
|
||||
test('对话项目归属尚未加载时不会误展开无项目', () => {
|
||||
const resolver = functionSource(projects, 'resolveChatProjectFolderSelection', 'renderChatProjectFolders');
|
||||
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
|
||||
|
||||
assert.match(resolver, /if \(!chatProjectFolderContext\.ready\) return null/);
|
||||
assert.match(resolver, /if \(!conversation\) return null/);
|
||||
assert.match(resolver, /conversation\.projectId \|\| conversation\.project_id \|\| ''/);
|
||||
assert.match(render, /const selectedId = resolveChatProjectFolderSelection\(\)/);
|
||||
assert.match(render, /selectedId !== null && chatProjectFolderLastSelectionId !== selectedId/);
|
||||
});
|
||||
|
||||
test('项目按展开状态切换 Codex 风格的打开和关闭文件夹', () => {
|
||||
const icon = functionSource(projects, 'projectFolderIconMarkup', 'clampProjectPreviewText');
|
||||
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(icon, /const path = isExpanded/);
|
||||
assert.match(icon, /M3\.5 18V6\.5/);
|
||||
assert.match(icon, /M3\.5 7a2 2 0 0 1 2-2h4l2 2/);
|
||||
assert.match(folder, /icon\.className = 'project-folder-icon';/);
|
||||
assert.match(folder, /icon\.innerHTML = projectFolderIconMarkup\(isExpanded\);/);
|
||||
});
|
||||
|
||||
test('项目名仅在界面按 12 个 Unicode 字符省略并保留完整悬浮信息', () => {
|
||||
const formatterSource = functionSource(chat, 'formatProjectNameForDisplay', 'applyProjectNameDisplay');
|
||||
const formatter = new Function(
|
||||
'PROJECT_NAME_DISPLAY_MAX_CHARACTERS',
|
||||
`${formatterSource}; return formatProjectNameForDisplay;`
|
||||
)(12);
|
||||
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
const picker = functionSource(projects, 'appendChatProjectPanelItem', 'appendChatProjectPanelMessage');
|
||||
const button = functionSource(projects, 'updateChatProjectButtonLabel', 'renderChatProjectPanel');
|
||||
|
||||
assert.equal(formatter('十二字符以内'), '十二字符以内');
|
||||
assert.equal(formatter('这是一个非常非常长的项目名称'), '这是一个非常非常长的项目…');
|
||||
assert.equal(formatter('😀😀😀😀😀😀😀😀😀😀😀😀😀'), '😀😀😀😀😀😀😀😀😀😀😀😀…');
|
||||
assert.match(chat, /const PROJECT_NAME_DISPLAY_MAX_CHARACTERS = 12/);
|
||||
assert.match(folder, /applyProjectNameDisplay\(title, project\.name/);
|
||||
assert.match(projects, /applyProjectNameDisplay\(titleEl, text\)/);
|
||||
assert.match(picker, /title="\$\{escapeAttr\(fullName\)\}"/);
|
||||
assert.match(picker, /setAttribute\('aria-label', fullName\)/);
|
||||
assert.match(button, /applyProjectNameDisplay/);
|
||||
assert.match(styles, /\.project-selector-wrapper \.role-selector-text\s*\{[\s\S]*?max-width: 13em/);
|
||||
});
|
||||
|
||||
test('项目文件夹首批显示 6 个并通过加载更多按批追加', () => {
|
||||
const loadMore = functionSource(projects, 'loadMoreChatProjectFolders', 'renderChatProjectFolders');
|
||||
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
|
||||
const search = functionSource(projects, 'handleProjectFolderSearch', 'clearProjectFolderSearch');
|
||||
|
||||
assert.match(projects, /const CHAT_PROJECT_FOLDER_PAGE_SIZE = 6/);
|
||||
assert.match(loadMore, /chatProjectFolderVisibleCount \+= CHAT_PROJECT_FOLDER_PAGE_SIZE/);
|
||||
assert.match(render, /const visibleFolders = folders\.slice\(0, chatProjectFolderVisibleCount\)/);
|
||||
assert.match(render, /appendChatProjectFoldersLoadMore\(list, folders\.length - visibleFolders\.length\)/);
|
||||
assert.match(render, /chatProjectFolderVisibleCount = selectedIndex \+ 1/);
|
||||
assert.match(search, /renderChatProjectFolders\(projectsCacheAll\)/);
|
||||
assert.match(styles, /\.project-folders-load-more\s*\{/);
|
||||
assert.match(zh, /"projectFoldersLoadMoreRemaining": "加载更多,剩余 \{\{count\}\} 个项目"/);
|
||||
assert.match(en, /"projectFoldersLoadMoreRemaining": "Load more, \{\{count\}\} projects remaining"/);
|
||||
});
|
||||
|
||||
test('对话悬浮预览显示本地年月日时分', () => {
|
||||
const age = functionSource(projects, 'formatProjectConversationPreviewAge', 'getProjectConversationModeLabel');
|
||||
|
||||
assert.match(age, /date\.getFullYear\(\)/);
|
||||
assert.match(age, /date\.getMonth\(\) \+ 1/);
|
||||
assert.match(age, /date\.getDate\(\)/);
|
||||
assert.match(age, /date\.getHours\(\)/);
|
||||
assert.match(age, /date\.getMinutes\(\)/);
|
||||
assert.match(age, /chat\.conversationPreviewDateTime/);
|
||||
assert.doesNotMatch(age, /elapsedMs|conversationPreviewDays|conversationPreviewHours/);
|
||||
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
|
||||
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
|
||||
});
|
||||
+1256
-12
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@
|
||||
// 项目
|
||||
showNewProjectModal: 'project:write',
|
||||
showNewProjectModalFromChat: 'project:write',
|
||||
showNewProjectModalFromChatSidebar: 'project:write',
|
||||
showNewProjectModalFromWebshellAi: 'project:write',
|
||||
showEditProjectModal: 'project:write',
|
||||
saveProjectModal: 'project:write',
|
||||
|
||||
+29
-2
@@ -17,6 +17,27 @@ function buildHashForPage(pageId) {
|
||||
}
|
||||
|
||||
let chatConversationFromHashSeq = 0;
|
||||
|
||||
function setChatConversationRestorePending(conversationId, pending) {
|
||||
const container = document.querySelector('.chat-container');
|
||||
if (!container) return;
|
||||
const id = String(conversationId || '').trim();
|
||||
if (pending && id) {
|
||||
container.classList.add('is-conversation-restoring');
|
||||
container.dataset.restoringConversationId = id;
|
||||
container.setAttribute('aria-busy', 'true');
|
||||
return;
|
||||
}
|
||||
container.classList.remove('is-conversation-restoring');
|
||||
delete container.dataset.restoringConversationId;
|
||||
container.removeAttribute('aria-busy');
|
||||
}
|
||||
|
||||
function finishChatConversationRestore(conversationId) {
|
||||
setChatConversationRestorePending(conversationId, false);
|
||||
}
|
||||
window.finishChatConversationRestore = finishChatConversationRestore;
|
||||
|
||||
function scheduleChatConversationFromHash(delayMs) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const hashParts = hash.split('?');
|
||||
@@ -35,6 +56,8 @@ function scheduleChatConversationFromHash(delayMs) {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
// 同一事件循环内先遮住默认新对话状态,避免网络请求返回前闪出“无项目”。
|
||||
setChatConversationRestorePending(conversationId, true);
|
||||
const token = ++chatConversationFromHashSeq;
|
||||
setTimeout(() => {
|
||||
if (token !== chatConversationFromHashSeq) {
|
||||
@@ -84,7 +107,7 @@ function initRouter() {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(500);
|
||||
scheduleChatConversationFromHash(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -98,6 +121,9 @@ function initRouter() {
|
||||
function switchPage(pageId) {
|
||||
const targetPage = document.getElementById(`page-${pageId}`);
|
||||
if (!targetPage) return;
|
||||
if (pageId !== 'chat') {
|
||||
setChatConversationRestorePending('', false);
|
||||
}
|
||||
|
||||
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
|
||||
// 同一页面已经激活时不再重复初始化,避免接口重复请求和页面二次重绘。
|
||||
@@ -563,6 +589,7 @@ async function initPage(pageId) {
|
||||
// 页面加载完成后初始化路由
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initRouter();
|
||||
document.documentElement.classList.remove('initial-route-pending');
|
||||
initSidebarState();
|
||||
|
||||
// 监听hash变化
|
||||
@@ -576,7 +603,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(200);
|
||||
scheduleChatConversationFromHash(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
|
||||
function timingSource(source) {
|
||||
const start = source.indexOf('function formatAssistantTurnDuration(');
|
||||
const end = source.indexOf('window.setAssistantTurnTiming = setAssistantTurnTiming;', start);
|
||||
assert.notEqual(start, -1, 'formatAssistantTurnDuration should exist');
|
||||
assert.notEqual(end, -1, 'timing exports should exist');
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createClassList() {
|
||||
return {
|
||||
toggle() {},
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage() {
|
||||
const label = {
|
||||
innerHTML: '',
|
||||
classList: createClassList(),
|
||||
setAttribute() {},
|
||||
};
|
||||
return {
|
||||
dataset: {},
|
||||
querySelector(selector) {
|
||||
if (selector === '.mcp-call-label.turn-process-summary') return label;
|
||||
return null;
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(nowMs) {
|
||||
const RealDate = Date;
|
||||
class TestDate extends RealDate {
|
||||
static now() {
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
Date: TestDate,
|
||||
Number,
|
||||
Math,
|
||||
String,
|
||||
document: {
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getElementById() { return null; },
|
||||
},
|
||||
window: {},
|
||||
escapeHtml(value) { return String(value); },
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('刷新运行中任务时忽略摘要中的零耗时并按开始时间恢复', () => {
|
||||
const startedAt = '2026-08-12T02:00:00.000Z';
|
||||
const startedMs = Date.parse(startedAt);
|
||||
const context = createHarness(startedMs + 65_000);
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt,
|
||||
durationMs: 0,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, undefined);
|
||||
assert.match(message.label.innerHTML, /已处理 1 分钟 5 秒/);
|
||||
});
|
||||
|
||||
test('已完成任务仍优先使用持久化耗时', () => {
|
||||
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt: '2026-08-12T02:00:00.000Z',
|
||||
completedAt: '2026-08-12T02:01:05.000Z',
|
||||
durationMs: 65_000,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, '65000');
|
||||
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
|
||||
});
|
||||
|
||||
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
|
||||
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt: '2026-08-11T09:47:14.000Z',
|
||||
completedAt: '2026-08-11T09:51:39.000Z',
|
||||
durationMs: 265_000,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, '265000');
|
||||
assert.match(message.label.innerHTML, /已中断 · 耗时 4 分钟 25 秒/);
|
||||
assert.doesNotMatch(message.label.innerHTML, /已处理/);
|
||||
});
|
||||
|
||||
test('历史占位消息存在取消事件时不会再判定为运行中', () => {
|
||||
assert.match(chat, /function assistantTurnTerminalState\(processDetails\)/);
|
||||
assert.match(chat, /const isRunning = isAssistantPlaceholder && !terminalState/);
|
||||
assert.match(chat, /status: status/);
|
||||
});
|
||||
+158
-51
@@ -23,10 +23,19 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260720-2">
|
||||
<script>
|
||||
(function () {
|
||||
var initialPage = String(window.location.hash || '').replace(/^#/, '').split('?')[0];
|
||||
if (initialPage && initialPage !== 'dashboard') {
|
||||
document.documentElement.classList.add('initial-route-pending');
|
||||
}
|
||||
})();
|
||||
</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"></script>
|
||||
<script src="/static/js/router.js?v=20260813-2"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||
@@ -889,10 +898,10 @@
|
||||
<div class="chat-page-layout">
|
||||
<!-- 历史对话侧边栏(可折叠,与主导航侧边栏类似) -->
|
||||
<aside class="conversation-sidebar" id="conversation-sidebar">
|
||||
<!-- 头部一行:折叠与「新对话」并排,避免绝对定位重叠(flex 为最佳实践) -->
|
||||
<!-- 头部一行:折叠与「新任务」并排;任务底层复用会话数据 -->
|
||||
<div class="sidebar-header conversation-sidebar-header">
|
||||
<button type="button" class="new-chat-btn" data-require-permission="chat:write" onclick="startNewConversation()">
|
||||
<span>+</span> <span data-i18n="chat.newChat">新对话</span>
|
||||
<span>+</span> <span data-i18n="chat.newTask">新任务</span>
|
||||
</button>
|
||||
<button type="button" class="conversation-sidebar-collapse-btn" onclick="toggleConversationSidebar()" data-i18n="chat.toggleConversationPanel" data-i18n-attr="title" data-i18n-skip-text="true" title="折叠/展开对话列表" aria-label="折叠/展开对话列表">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
@@ -901,13 +910,13 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<!-- 全局搜索 -->
|
||||
<!-- 项目搜索 -->
|
||||
<div class="conversation-search-box">
|
||||
<input type="text" id="conversation-search-input" data-i18n="chat.searchHistory" data-i18n-attr="placeholder" placeholder="搜索历史记录..."
|
||||
oninput="handleConversationSearch(this.value)"
|
||||
onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" />
|
||||
<input type="text" id="conversation-search-input" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索项目…"
|
||||
oninput="handleProjectFolderSearch(this.value)"
|
||||
onkeypress="if(event.key === 'Enter') handleProjectFolderSearch(this.value)" />
|
||||
<button class="conversation-search-clear" id="conversation-search-clear"
|
||||
onclick="clearConversationSearch()" style="display: none;" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索">
|
||||
onclick="clearProjectFolderSearch()" style="display: none;" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="7" fill="currentColor" fill-opacity="0.2"/>
|
||||
<path d="M5.25 5.25l5.5 5.5M10.75 5.25l-5.5 5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
@@ -915,8 +924,21 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 项目文件夹:复用对话底部项目选择器的真实项目数据 -->
|
||||
<section class="project-folders-section" aria-labelledby="project-folders-title">
|
||||
<div class="section-header project-folders-header">
|
||||
<span id="project-folders-title" class="section-title" data-i18n="chat.projectFolders">项目</span>
|
||||
<button type="button" class="add-group-btn project-folders-add-btn" data-require-permission="project:write" onclick="showNewProjectModalFromChatSidebar()" data-i18n="projects.newProject" data-i18n-attr="title,aria-label" data-i18n-skip-text="true" title="新建项目" aria-label="新建项目">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="project-folders-list" class="project-folders-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- 按项目筛选对话 -->
|
||||
<div class="conversation-project-filter">
|
||||
<div class="conversation-project-filter" hidden>
|
||||
<label class="conversation-project-filter-label" for="conversation-project-filter" data-i18n="chat.filterByProject">按项目筛选</label>
|
||||
<select id="conversation-project-filter" class="conversation-project-filter-native" onchange="onConversationProjectFilterChange(this.value)" data-i18n="chat.filterByProject" data-i18n-attr="title" title="按项目筛选">
|
||||
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
|
||||
@@ -924,24 +946,17 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 对话分组 -->
|
||||
<div class="conversation-groups-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="chat.conversationGroups">对话分组</span>
|
||||
<button class="add-group-btn" data-require-permission="group:write" onclick="showCreateGroupModal()" data-i18n="chat.addGroup" data-i18n-attr="title" data-i18n-skip-text="true" title="新建分组">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="conversation-groups-list" class="conversation-groups-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 最近对话 -->
|
||||
<div class="recent-conversations-section">
|
||||
<div class="section-header">
|
||||
<div class="recent-conversations-section is-collapsed" id="recent-conversations-section">
|
||||
<button type="button" class="section-header recent-conversations-toggle" id="recent-conversations-toggle" onclick="toggleRecentConversations()" aria-expanded="false" aria-controls="recent-conversations-body" data-i18n="chat.toggleRecentConversations" data-i18n-attr="title,aria-label" data-i18n-skip-text="true" title="展开/折叠最近对话" aria-label="展开/折叠最近对话">
|
||||
<span class="section-title" data-i18n="chat.recentConversations">最近对话</span>
|
||||
<div class="section-header-actions">
|
||||
<span class="recent-conversations-toggle-meta">
|
||||
<span id="recent-conversations-count" class="recent-conversations-count">0</span>
|
||||
<svg class="recent-conversations-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id="recent-conversations-body" class="recent-conversations-body" hidden>
|
||||
<div class="section-header-actions recent-conversations-actions">
|
||||
<div class="conversation-sort-dropdown" id="conversation-sort-dropdown">
|
||||
<button type="button" class="conversation-sort-btn" id="conversation-sort-btn" onclick="toggleConversationSortMenu(event)" aria-haspopup="menu" aria-expanded="false" aria-controls="conversation-sort-menu" data-i18n="chat.sortConversations" data-i18n-attr="title" data-i18n-skip-text="true" title="排序">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
@@ -984,8 +999,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="conversations-list" class="conversations-list"></div>
|
||||
</div>
|
||||
<div id="conversations-list" class="conversations-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
|
||||
@@ -1068,10 +1083,20 @@
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-timeout-field">
|
||||
<label class="hitl-config-label" for="hitl-timeout-select" data-i18n="chat.hitlTimeoutLabel">审批等待时限</label>
|
||||
<select id="hitl-timeout-select" class="hitl-config-select">
|
||||
<option value="60" data-i18n="chat.hitlTimeoutOneMinute">1 分钟</option>
|
||||
<option value="300" selected data-i18n="chat.hitlTimeoutFiveMinutes">5 分钟</option>
|
||||
<option value="600" data-i18n="chat.hitlTimeoutTenMinutes">10 分钟</option>
|
||||
<option value="0" data-i18n="chat.hitlTimeoutUnlimited">不限制</option>
|
||||
</select>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlTimeoutHint">到期未处理将自动拒绝;审批卡片会显示倒计时。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="6" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1140,13 +1165,33 @@
|
||||
</div>
|
||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||
<div id="chat-messages" class="chat-messages"></div>
|
||||
<button type="button" id="chat-scroll-to-bottom" class="chat-scroll-to-bottom" aria-label="回到底部" title="回到底部">↓ 回到底部</button>
|
||||
<nav id="chat-turn-rail" class="chat-turn-rail" aria-label="对话轮次导航" hidden>
|
||||
<div id="chat-turn-rail-markers" class="chat-turn-rail-markers"></div>
|
||||
</nav>
|
||||
<div id="chat-turn-rail-preview" class="chat-turn-rail-preview" role="tooltip" hidden>
|
||||
<div id="chat-turn-rail-preview-title" class="chat-turn-rail-preview-title"></div>
|
||||
<div id="chat-turn-rail-preview-summary" class="chat-turn-rail-preview-summary"></div>
|
||||
</div>
|
||||
<button type="button" id="chat-return-latest" class="chat-return-latest" hidden
|
||||
data-i18n="chat.returnToLatest" data-i18n-attr="title,aria-label" data-i18n-skip-text="true"
|
||||
title="回到最新消息" aria-label="回到最新消息">
|
||||
<span class="chat-return-latest-dots" aria-hidden="true">
|
||||
<span class="chat-return-latest-dot"></span>
|
||||
<span class="chat-return-latest-dot"></span>
|
||||
<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-input-primary-row">
|
||||
<div class="chat-composer-context" aria-label="当前会话上下文">
|
||||
<div class="chat-input-leading">
|
||||
<div class="role-selector-wrapper project-selector-wrapper">
|
||||
<button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)" data-i18n="projects.chatSelectorButton" data-i18n-attr="aria-label,title">
|
||||
<span class="role-selector-icon" aria-hidden="true">📁</span>
|
||||
<span class="role-selector-icon" aria-hidden="true">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 6.5A2.5 2.5 0 0 1 6 4h4l2 2h6A2.5 2.5 0 0 1 20.5 8.5v8A2.5 2.5 0 0 1 18 19H6a2.5 2.5 0 0 1-2.5-2.5v-10z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span id="chat-project-text" class="role-selector-text" data-i18n="projects.noProject">无项目</span>
|
||||
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
@@ -1253,6 +1298,9 @@
|
||||
<input type="hidden" id="agent-mode-select" value="eino_single" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="chat-hitl-approval-dock" class="chat-hitl-approval-dock" aria-label="待审批操作" aria-live="polite" hidden></section>
|
||||
<div class="chat-input-primary-row chat-composer-surface">
|
||||
<div class="chat-input-with-files">
|
||||
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
|
||||
<div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite">
|
||||
@@ -1260,22 +1308,76 @@
|
||||
<span class="chat-upload-progress-label" id="chat-attachment-progress-label"></span>
|
||||
</div>
|
||||
<div class="chat-input-field">
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" data-i18n-skip-text="true" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" data-i18n-skip-text="true" placeholder="输入测试目标或命令… @ 选择工具" rows="1"></textarea>
|
||||
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
|
||||
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" data-i18n="chat.uploadFile" data-i18n-attr="title" title="上传文件(可多选或拖拽到此处)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="send-btn" id="chat-send-btn" data-require-permission="chat:write" onclick="sendMessage()">
|
||||
<span data-i18n="chat.send">发送</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="chat-composer-footer">
|
||||
<div class="chat-composer-footer-leading">
|
||||
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
|
||||
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" data-i18n="chat.uploadFile" data-i18n-attr="title,aria-label" data-i18n-skip-text="true" title="上传文件(可多选或拖拽到此处)" aria-label="上传文件(可多选或拖拽到此处)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" id="chat-hitl-shortcut" class="chat-session-shortcut chat-hitl-shortcut" onclick="openChatSessionSettings('hitl', event)" data-i18n="chat.sessionSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="打开会话设置" title="打开会话设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 3l7 3v5c0 4.6-2.8 8.2-7 10-4.2-1.8-7-5.4-7-10V6l7-3z" stroke="currentColor" stroke-width="1.65" stroke-linejoin="round"/>
|
||||
<path d="M9.5 12l1.7 1.7 3.5-3.7" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span id="chat-hitl-shortcut-text">Agent 审查:关闭</span>
|
||||
<svg class="chat-session-shortcut-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-composer-footer-trailing">
|
||||
<div id="chat-model-shortcut-wrap" class="chat-model-shortcut-wrap">
|
||||
<button type="button" id="chat-model-shortcut" class="chat-session-shortcut chat-session-meta" onclick="openChatSystemModelPicker(event)" data-i18n="chat.modelSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择模型与推理强度" title="选择模型与推理强度" aria-haspopup="dialog" aria-expanded="false" aria-controls="chat-system-model-menu">
|
||||
<span id="chat-model-shortcut-text">默认通道</span>
|
||||
<span id="chat-model-shortcut-effort" class="chat-model-shortcut-effort">不指定</span>
|
||||
<svg class="chat-system-model-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="chat-system-model-menu" class="chat-system-model-menu" role="dialog" aria-label="模型与推理强度" hidden>
|
||||
<div id="chat-system-model-main" class="chat-system-model-main">
|
||||
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('model', event)">
|
||||
<span class="chat-system-model-setting-label" data-i18n="chat.systemModelField">模型</span>
|
||||
<span class="chat-system-model-setting-value">
|
||||
<span id="chat-system-model-current-value">默认通道</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('effort', event)">
|
||||
<span class="chat-system-model-setting-label" data-i18n="chat.reasoningEffortLabel">推理强度</span>
|
||||
<span class="chat-system-model-setting-value">
|
||||
<span id="chat-system-model-effort-value">不指定</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<span id="chat-system-model-status" class="chat-system-model-status chat-system-model-status-live" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chat-system-model-subview" class="chat-system-model-subview" hidden>
|
||||
<div class="chat-system-model-menu-header">
|
||||
<button type="button" class="chat-system-model-back" onclick="openChatSystemModelView('main', event)" aria-label="返回">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<strong id="chat-system-model-subview-title">模型</strong>
|
||||
<span id="chat-system-model-subview-status" class="chat-system-model-status"></span>
|
||||
</div>
|
||||
<div id="chat-system-model-list" class="chat-system-model-list" role="listbox" aria-label="选项列表"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="send-btn" id="chat-send-btn" data-require-permission="chat:write" onclick="sendMessage()" data-i18n="chat.send" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="发送" title="发送">
|
||||
<span class="send-btn-label" data-i18n="chat.send">发送</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 19V5M6.5 10.5L12 5l5.5 5.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5999,6 +6101,9 @@
|
||||
<div id="projects-list-menu-edit" class="context-menu-item" data-require-permission="project:write" onclick="editProjectFromListMenu()">
|
||||
<span id="projects-list-menu-edit-text"></span>
|
||||
</div>
|
||||
<div id="projects-list-menu-pin" class="context-menu-item" data-require-permission="project:write" onclick="toggleProjectPinnedFromListMenu()">
|
||||
<span id="projects-list-menu-pin-text"></span>
|
||||
</div>
|
||||
<div id="projects-list-menu-archive" class="context-menu-item" data-require-permission="project:write" onclick="toggleProjectArchiveFromListMenu()">
|
||||
<span id="projects-list-menu-archive-text"></span>
|
||||
</div>
|
||||
@@ -6524,6 +6629,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fact-modal" class="modal-overlay projects-modal-overlay" style="display:none;" role="dialog" aria-modal="true" onclick="if(event.target===this)closeFactModal()">
|
||||
<div class="projects-modal-dialog projects-modal-dialog--wide" onclick="event.stopPropagation()">
|
||||
<div class="projects-modal-header">
|
||||
@@ -6707,17 +6813,18 @@
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/builtin-tools.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/auth.js?v=20260813-1"></script>
|
||||
<script src="/static/js/modal.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/info-collect.js?v=20260717-1"></script>
|
||||
<script src="/static/js/assets.js?v=20260717-7"></script>
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js"></script>
|
||||
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260724-1"></script>
|
||||
<script src="/static/js/hitl.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-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>
|
||||
<script src="/static/js/audit.js"></script>
|
||||
@@ -6728,7 +6835,7 @@
|
||||
<script src="/static/js/knowledge.js"></script>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js?v=20260717-1"></script>
|
||||
<script src="/static/js/projects.js?v=20260812-6"></script>
|
||||
<script src="/static/js/vulnerability.js?v=14"></script>
|
||||
<script src="/static/js/webshell.js"></script>
|
||||
<script src="/static/js/chat-files.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user