优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)

* feat(chat): add project-based conversation sidebar

* feat(chat): refine Codex-style conversation UI

* feat(chat): add Codex-style conversation workflow

* feat(ui): 优化对话框与项目侧边栏交互

* fix(chat): 修复暗色输入框圆角填色

* fix(chat): 恢复输入区分层错位布局

* fix(hitl): isolate reviewer state per conversation

* feat(hitl): 增加双入口审批与倒计时进度

* fix(hitl): 汇总项目审批并隔离对话状态

* fix(ui): 修复审批状态与无项目新任务

* fix(ui): 优化审批状态与对话切换性能

* fix(ui): 修复中断任务审批仍计时

* fix(ui): 修复多对话并发切换卡顿

* fix(hitl): 主动同步审批并关闭中断状态

* fix(ui): 固定项目审批汇总为绿色

* feat(ui): 同步系统模型与推理强度

* feat(ui): 完善项目侧栏预览与新建入口

* fix(ui): 防止无项目文件夹误展开

* fix(ui): 防止长历史对话滚动误触审批

* fix(ui): 修复对话操作并补充项目置顶

* fix(ui): 移除对话分组并调整项目置顶排序

* feat(ui): 优化迭代导航与审批交互

* fix(hitl): 将 write_file 加入内置免审批工具

* fix(chat): 支持回车发送与 Shift 回车换行

* fix(chat): 优化对话刷新与 Codex 风格交互

* fix(ui): 显示对话具体更新时间

* fix(ui): 优化对话刷新与项目加载

* fix(ui): 修复 Agent 审查文字裁切

* fix(chat): 修复刷新续流与多标签页同步

* fix(chat): 修复滚动跟随与中断任务终态

* fix(ui): 修复流式滚动跳动与工具状态恢复

* fix(ui): 修复刷新后流式输出停止粘底
This commit is contained in:
RuoJi6
2026-08-13 21:28:37 +08:00
committed by GitHub
parent b170f2c4b1
commit eb6bab574f
31 changed files with 10763 additions and 886 deletions
+85
View File
@@ -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
}
@@ -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())
+56 -23
View File
@@ -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处理器
@@ -976,14 +985,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 +1002,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 +1091,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 +1352,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 +1470,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 +1490,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()
+280 -57
View File
@@ -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
}
+16 -10
View File
@@ -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) {
+236
View File
@@ -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)
}
}
+21
View File
@@ -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")
}
}
@@ -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",
@@ -33,29 +33,30 @@ 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)
}
foundProjectFactTools := map[string]bool{
foundBuiltInTools := map[string]bool{
"write_file": false,
"upsert_project_fact": false,
"get_project_fact": false,
}
for _, name := range merged {
normalized := strings.ToLower(strings.TrimSpace(name))
if _, ok := foundProjectFactTools[normalized]; ok {
foundProjectFactTools[normalized] = true
if _, ok := foundBuiltInTools[normalized]; ok {
foundBuiltInTools[normalized] = true
}
}
for name, found := range foundProjectFactTools {
for name, found := range foundBuiltInTools {
if !found {
t.Fatalf("%s missing from %v", name, merged)
}
+3033 -171
View File
File diff suppressed because it is too large Load Diff
+121 -5
View File
@@ -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,40 @@
},
"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",
"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 +603,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 +619,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 +659,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 +722,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 +760,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 +802,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 +833,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 35 minutes",
"approvalUrgencyOneToThree": "Earliest approval expires in 13 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",
+121 -5
View File
@@ -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,40 @@
},
"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": "回到最新消息",
"completedUnread": "已完成,尚未查看",
"newConversationInProject": "在此项目中新建对话",
"newUnassignedConversation": "新建无项目对话",
"conversationActions": "对话操作",
"renameConversationPrompt": "请输入新标题:",
"renameConversationTitle": "重命名对话",
"renameConversationSubtitle": "修改后会同步更新项目文件夹和最近对话中的名称",
"conversationTitleLabel": "对话名称",
"conversationTitlePlaceholder": "请输入对话名称",
"conversationGroups": "对话分组",
"addGroup": "新建分组",
"recentConversations": "最近对话",
"toggleRecentConversations": "展开/折叠最近对话",
"filterByProject": "按项目筛选",
"filterAllProjects": "全部项目",
"filterUnboundProjects": "未绑定项目",
@@ -559,7 +591,7 @@
"viewAttackChain": "查看攻击链",
"selectRole": "选择角色",
"defaultRole": "默认",
"inputPlaceholder": "输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)",
"inputPlaceholder": "输入测试目标或命令… @ 选择工具",
"selectFile": "选择文件",
"uploadFile": "上传文件(可多选或拖拽到此处)",
"readingAttachmentsDetail": "读取附件 {{current}}/{{total}} · {{name}} · {{percent}}%",
@@ -575,10 +607,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 +647,12 @@
"executeFailed": "执行失败",
"callOpenAIFailed": "调用OpenAI失败",
"systemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"projectWelcomeMessage": "当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
"noProjectWelcomeMessage": "当前无项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
"projectWelcomeTitlePrefix": "要在 ",
"projectWelcomeTitleSuffix": " 项目中测试什么?",
"noProjectWelcomeTitle": "要测试什么?",
"welcomeSubtitle": "请输入您的测试需求,系统将自动执行相应的安全测试。",
"addNewGroup": "+ 新增分组",
"callNumber": "调用 #{{n}}",
"iterationRound": "第 {{n}} 轮迭代",
@@ -662,15 +710,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 +748,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 +790,12 @@
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
"hitlApplyOkLocal": "已保存到本浏览器。",
"hitlApplyFail": "同步到服务器失败",
"hitlTimeoutLabel": "审批等待时限",
"hitlTimeoutOneMinute": "1 分钟",
"hitlTimeoutFiveMinutes": "5 分钟",
"hitlTimeoutTenMinutes": "10 分钟",
"hitlTimeoutUnlimited": "不限制",
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
"hitlStatusOff": "人机协同:关闭"
},
"hitl": {
@@ -751,6 +821,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": "搜索",
+9
View File
@@ -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() {
+44
View File
@@ -0,0 +1,44 @@
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');
});
@@ -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);
});
+398
View File
@@ -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-3/);
});
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-5/);
});
+549 -49
View File
@@ -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;
+1650 -215
View File
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 \}\)/);
});
+328
View File
@@ -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-3/);
assert.match(template, /style\.css\?v=20260813-5/);
});
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
View File
@@ -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\)/);
});
+1708 -238
View File
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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -42,6 +42,7 @@
// 项目
showNewProjectModal: 'project:write',
showNewProjectModalFromChat: 'project:write',
showNewProjectModalFromChatSidebar: 'project:write',
showNewProjectModalFromWebshellAi: 'project:write',
showEditProjectModal: 'project:write',
saveProjectModal: 'project:write',
+29 -2
View File
@@ -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/);
});
+155 -51
View File
@@ -23,10 +23,18 @@
}
})();
</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-5">
<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 +897,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 +909,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 +923,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 +945,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 +998,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 +1082,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 +1164,32 @@
</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="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 +1296,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 +1306,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 +6099,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 +6627,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 +6811,17 @@
<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-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 +6832,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>