diff --git a/internal/database/conversation.go b/internal/database/conversation.go index e54cbeba..7ecac55e 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -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 } diff --git a/internal/database/process_details_summary_test.go b/internal/database/process_details_summary_test.go index d436f4b0..200f6b7d 100644 --- a/internal/database/process_details_summary_test.go +++ b/internal/database/process_details_summary_test.go @@ -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()) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 1a1067b5..5a5c0437 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -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) } } } diff --git a/internal/handler/agent_progress_callback_test.go b/internal/handler/agent_progress_callback_test.go index 5447c9a7..3cab9b58 100644 --- a/internal/handler/agent_progress_callback_test.go +++ b/internal/handler/agent_progress_callback_test.go @@ -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() diff --git a/internal/handler/hitl.go b/internal/handler/hitl.go index 0fb5f356..c940a6ba 100644 --- a/internal/handler/hitl.go +++ b/internal/handler/hitl.go @@ -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 } diff --git a/internal/handler/hitl_logs.go b/internal/handler/hitl_logs.go index 973c6626..a6d787e0 100644 --- a/internal/handler/hitl_logs.go +++ b/internal/handler/hitl_logs.go @@ -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) { diff --git a/internal/handler/hitl_restart_test.go b/internal/handler/hitl_restart_test.go new file mode 100644 index 00000000..f5ec1475 --- /dev/null +++ b/internal/handler/hitl_restart_test.go @@ -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) + } +} diff --git a/internal/handler/hitl_whitelist_test.go b/internal/handler/hitl_whitelist_test.go new file mode 100644 index 00000000..5fcbfd4d --- /dev/null +++ b/internal/handler/hitl_whitelist_test.go @@ -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") + } +} diff --git a/internal/multiagent/hitl_toolsearch_compat.go b/internal/multiagent/hitl_toolsearch_compat.go index 208c0c30..d54d08de 100644 --- a/internal/multiagent/hitl_toolsearch_compat.go +++ b/internal/multiagent/hitl_toolsearch_compat.go @@ -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", diff --git a/internal/multiagent/hitl_toolsearch_compat_test.go b/internal/multiagent/hitl_toolsearch_compat_test.go index fbf11acc..4fde50ee 100644 --- a/internal/multiagent/hitl_toolsearch_compat_test.go +++ b/internal/multiagent/hitl_toolsearch_compat_test.go @@ -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) } diff --git a/web/static/css/style.css b/web/static/css/style.css index 17e8ecb6..39b9834b 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -480,6 +480,12 @@ html[data-theme="dark"] .main-sidebar { background: #f5f7fa; } +/* 模板默认 active 页是仪表盘。带其他 hash 直接刷新时,在路由脚本同步选中 + 目标页之前隐藏内容区,避免先闪出仪表盘再跳转。 */ +html.initial-route-pending .content-area { + visibility: hidden; +} + /* 对话页面不需要page-header */ #page-chat .page-header { display: none; @@ -1923,8 +1929,10 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .hitl-config-textarea { display: block; width: 100%; - min-height: 68px; - max-height: 150px; + height: 148px; + min-height: 96px; + max-height: 280px; + overflow-y: auto; resize: vertical; border: 1px solid var(--border-color); border-radius: 8px; @@ -3409,6 +3417,16 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { position: relative; } +/* 带 conversation hash 刷新时,在目标对话数据完成恢复前不展示默认新对话状态。 + 该状态在同一事件循环内设置,避免“无项目”与欢迎页短暂闪现。 */ +.chat-container.is-conversation-restoring #active-tasks-bar, +.chat-container.is-conversation-restoring #chat-messages, +.chat-container.is-conversation-restoring #chat-turn-rail, +.chat-container.is-conversation-restoring #chat-return-latest, +.chat-container.is-conversation-restoring #chat-input-container { + visibility: hidden; +} + /* 会话顶部栏样式 */ .conversation-header { background: transparent; @@ -3428,55 +3446,281 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 24px; + padding: 24px 24px 24px 64px; background: #f5f7fa; display: flex; flex-direction: column; min-height: 0; } -.chat-scroll-to-bottom { +.chat-welcome-empty-state { + flex: 1 0 auto; + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px 96px; + box-sizing: border-box; + color: var(--text-primary); + text-align: center; +} + +.chat-welcome-empty-state-title { + width: min(100%, 900px); + margin: 0; + font-size: clamp(1.85rem, 3vw, 3rem); + font-weight: 400; + line-height: 1.25; + letter-spacing: -0.025em; +} + +.chat-welcome-project-name { + display: inline; + border-bottom: 1px dotted currentColor; + padding-bottom: 0.08em; +} + +.chat-welcome-empty-state-subtitle { + width: min(100%, 720px); + margin: 18px 0 0; + color: var(--text-secondary); + font-size: clamp(0.95rem, 1.15vw, 1.1rem); + font-weight: 400; + line-height: 1.6; + letter-spacing: 0; +} + +.chat-turn-rail { position: absolute; - right: 24px; - bottom: 88px; + left: 18px; + top: calc((100% - var(--chat-composer-total-height, 170px)) / 2); z-index: 20; - padding: 8px 14px; - border-radius: 20px; - border: 1px solid rgba(0, 102, 255, 0.25); - background: rgba(255, 255, 255, 0.96); - color: var(--accent-color); - font-size: 0.8125rem; - font-weight: 500; - line-height: 1.2; - cursor: pointer; - box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12); - opacity: 0; + width: 44px; + transform: translateY(-50%); pointer-events: none; - transform: translateY(8px); - transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } -.chat-scroll-to-bottom.visible { - opacity: 1; +.chat-turn-rail[hidden] { + display: none; +} + +.chat-turn-rail-markers { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; + width: 100%; + box-sizing: border-box; + max-height: min(48vh, 336px); + padding: 10px 4px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + touch-action: pan-y; + scrollbar-width: none; pointer-events: auto; - transform: translateY(0); } -.chat-scroll-to-bottom:hover { - background: #fff; - box-shadow: 0 6px 20px rgba(15, 23, 42, 0.16); +.chat-turn-rail-markers::-webkit-scrollbar { + display: none; } -.chat-scroll-to-bottom:focus-visible { - outline: 2px solid var(--accent-color); - outline-offset: 2px; +.chat-turn-rail-marker { + display: block; + position: relative; + width: 36px; + height: 11px; + min-height: 11px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.chat-turn-rail-marker::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 12px; + height: 3px; + border-radius: 999px; + background: #c7cbd1; + transform: translateY(-50%); + transform-origin: left center; + transition: width 0.16s ease, background-color 0.16s ease, opacity 0.16s ease; +} + +.chat-turn-rail-marker:hover::before { + width: 22px; + background: #7c828b; +} + +.chat-turn-rail-marker:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.35); + outline-offset: 3px; +} + +.chat-turn-rail-marker.is-active::before { + width: 12px; + background: #20242a; +} + +.chat-turn-rail-marker.is-active:hover::before { + width: 22px; + background: #20242a; +} + +.chat-turn-rail-marker.has-pending-new:last-child::before { + background: var(--primary-color, #2563eb); + animation: chat-turn-marker-pulse 1.2s ease-in-out infinite; +} + +@keyframes chat-turn-marker-pulse { + 0%, 100% { opacity: 0.58; } + 50% { opacity: 1; } +} + +.chat-turn-rail-preview { + position: fixed; + z-index: 80; + width: min(520px, calc(100vw - 120px)); + padding: 16px 18px; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + color: #20242a; + box-shadow: 0 14px 38px rgba(15, 23, 42, 0.13); + backdrop-filter: blur(14px); + pointer-events: auto; +} + +.chat-turn-rail-preview[hidden] { + display: none; +} + +.chat-turn-rail-preview-title { + overflow: hidden; + color: #20242a; + font-size: 15px; + font-weight: 650; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-turn-rail-preview-summary { + display: -webkit-box; + margin-top: 7px; + overflow: hidden; + color: #858a92; + font-size: 14px; + font-weight: 500; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.chat-return-latest { + position: absolute; + left: 50%; + bottom: calc(var(--chat-composer-total-height, 170px) + 10px); + z-index: 24; + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 50%; + background: rgba(255, 255, 255, 0.97); + color: #646b75; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(12px); + cursor: pointer; + transform: translateX(-50%); + transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; +} + +.chat-return-latest[hidden] { + display: none !important; +} + +.chat-return-latest:hover { + border-color: rgba(15, 23, 42, 0.18); + background: #ffffff; + color: #303640; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.08); + transform: translateX(-50%) translateY(-1px); +} + +.chat-return-latest:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.42); + outline-offset: 3px; +} + +.chat-return-latest-dots { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 14px; +} + +.chat-return-latest-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.chat-return-latest.is-streaming .chat-return-latest-dot { + animation: chat-return-latest-dot-bounce 0.9s ease-in-out infinite; +} + +.chat-return-latest.is-streaming .chat-return-latest-dot:nth-child(2) { + animation-delay: 0.12s; +} + +.chat-return-latest.is-streaming .chat-return-latest-dot:nth-child(3) { + animation-delay: 0.24s; +} + +@keyframes chat-return-latest-dot-bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-3px); } +} + +@media (max-width: 820px) { + .chat-turn-rail { + display: none !important; + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-turn-rail-marker::before { + transition: none; + } + + .chat-turn-rail-marker.has-pending-new:last-child::before { + animation: none; + } + + .chat-return-latest, + .chat-return-latest.is-streaming .chat-return-latest-dot { + transition: none; + animation: none; + } } .message { margin-bottom: 24px; display: flex; align-items: flex-start; - gap: 12px; + gap: 0; animation: fadeIn 0.3s ease-in; width: 100%; } @@ -3493,7 +3737,6 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .message.user { - flex-direction: row-reverse; justify-content: flex-end; } @@ -3502,46 +3745,6 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { margin-bottom: 16px; } -.message-avatar { - width: 32px; - height: 32px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; - flex-shrink: 0; -} - -.message-avatar svg { - width: 17px; - height: 16px; - flex-shrink: 0; -} - -.message-avatar-img { - width: 20px; - height: 20px; - border-radius: 4px; - object-fit: contain; -} - -.message.user .message-avatar { - background: var(--accent-color); - color: white; -} - -.message.assistant .message-avatar { - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.message.system .message-avatar { - display: none; -} - .message-content { flex: 0 1 auto; max-width: 70%; @@ -3556,12 +3759,15 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .message.user .message-content { align-items: flex-end; margin-left: auto; + width: auto; + max-width: min(78%, 760px); } .message.assistant .message-content { align-items: flex-start; margin-right: auto; min-width: 0; + max-width: min(100%, 920px); } .message.system .message-content { @@ -3976,18 +4182,20 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .message.user .message-bubble { - background: var(--accent-color); - color: white; - border-bottom-right-radius: 8px; - border-top-right-radius: 2px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid transparent; + border-radius: 20px; + box-shadow: none; } -.message.assistant .message-bubble { - background: var(--bg-primary); +.message.assistant:not(.progress-message):not(.assistant-turn-with-process) .message-bubble { + background: transparent; color: var(--text-primary); - border: 1px solid var(--border-color); - border-bottom-left-radius: 8px; - border-top-left-radius: 2px; + border: 0; + border-radius: 0; + box-shadow: none; + padding: 0; } .message.assistant.assistant-not-finalized .message-bubble { @@ -4190,32 +4398,6 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { cursor: pointer; } -.process-details-jump-latest { - position: sticky; - bottom: 12px; - z-index: 4; - display: block; - width: max-content; - max-width: calc(100% - 24px); - margin: -42px 12px 10px auto; - padding: 7px 12px; - border: 1px solid rgba(59, 130, 246, 0.35); - border-radius: 999px; - background: var(--bg-primary); - color: var(--primary-color, #2563eb); - box-shadow: 0 4px 14px rgba(15, 23, 42, 0.14); - opacity: 0; - pointer-events: none; - transform: translateY(6px); - transition: opacity 0.18s ease, transform 0.18s ease; -} - -.process-details-jump-latest.visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - .chat-input-container { display: flex; flex-direction: row; @@ -4262,11 +4444,28 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .conversation-reasoning-card.conversation-reasoning-collapsed { - height: var(--chat-input-bar-height, 65px); + height: 72px; + min-height: 72px; box-sizing: border-box; padding: 12px 16px; } +.conversation-reasoning-card:not(.conversation-reasoning-collapsed) { + position: absolute; + z-index: 80; + left: 12px; + right: 12px; + bottom: 12px; + width: auto; + max-height: min(68vh, 620px); + padding: 14px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 16px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18), 0 3px 10px rgba(15, 23, 42, 0.08); +} + .conversation-reasoning-card-header { display: flex; align-items: center; @@ -4383,6 +4582,20 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.3s ease; } +.conversation-reasoning-card:not(.conversation-reasoning-collapsed) .conversation-reasoning-body { + max-height: calc(min(68vh, 620px) - 64px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 3px; +} + +html[data-theme="dark"] .conversation-reasoning-card:not(.conversation-reasoning-collapsed) { + background: rgba(15, 23, 42, 0.98); + border-color: #2b374b; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42), 0 3px 10px rgba(0, 0, 0, 0.28); +} + .conversation-reasoning-card.conversation-reasoning-collapsed .conversation-reasoning-body { max-height: 0; opacity: 0; @@ -5669,7 +5882,7 @@ html[data-theme="dark"] .ai-channel-editor-form select { width: 100%; } -.chat-input-container textarea { +.chat-input-container .chat-input-field > #chat-input { flex: 1; min-width: 0; padding: 10px 14px; @@ -5695,35 +5908,35 @@ html[data-theme="dark"] .ai-channel-editor-form select { } /* WebKit 浏览器(Chrome, Safari, Edge)的滚动条样式 - 隐藏但保留功能 */ -.chat-input-container textarea::-webkit-scrollbar { +.chat-input-container .chat-input-field > #chat-input::-webkit-scrollbar { width: 4px; /* 最窄的滚动条 */ } -.chat-input-container textarea::-webkit-scrollbar-track { +.chat-input-container .chat-input-field > #chat-input::-webkit-scrollbar-track { background: transparent; /* 隐藏轨道 */ } -.chat-input-container textarea::-webkit-scrollbar-thumb { +.chat-input-container .chat-input-field > #chat-input::-webkit-scrollbar-thumb { background: transparent; /* 默认隐藏滑块 */ border-radius: 2px; } /* 鼠标悬停时显示滚动条 */ -.chat-input-container textarea:hover::-webkit-scrollbar-thumb { +.chat-input-container .chat-input-field > #chat-input:hover::-webkit-scrollbar-thumb { background: var(--text-muted); /* 悬停时显示 */ } -.chat-input-container textarea:focus::-webkit-scrollbar-thumb { +.chat-input-container .chat-input-field > #chat-input:focus::-webkit-scrollbar-thumb { background: var(--text-muted); /* 聚焦时显示 */ } -.chat-input-container textarea:focus { +.chat-input-container .chat-input-field > #chat-input:focus { border-color: var(--accent-color); box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.08); background: #ffffff; } -.chat-input-container textarea::placeholder { +.chat-input-container .chat-input-field > #chat-input::placeholder { color: var(--text-muted); opacity: 0.85; } @@ -7242,13 +7455,14 @@ html[data-theme="dark"] .login-card .login-submit:disabled { overflow-y: auto; } -/* 流式执行中:取消时间线内层滚动,由 #chat-messages 统一跟随 */ +/* 流式执行中:内层迭代与外层 #chat-messages 各自滚动、各自跟随最新内容。 */ .progress-container.is-streaming .progress-timeline.expanded, .process-details-container.is-streaming .process-details-content .progress-timeline.expanded { - max-height: none; + max-height: min(64vh, 720px); overflow-x: hidden; - overflow-y: visible; + overflow-y: auto; overscroll-behavior: auto; + scrollbar-gutter: stable; } .timeline-item { @@ -7875,8 +8089,8 @@ html[data-theme="dark"] .login-card .login-submit:disabled { display: none; align-items: center; gap: 12px; - padding: 10px 16px; - margin: 12px 0; + padding: 13px 24px 14px; + margin: 0; background: var(--bg-primary); border: 1px solid rgba(0, 102, 255, 0.15); border-radius: 10px; @@ -7895,7 +8109,8 @@ html[data-theme="dark"] .login-card .login-submit:disabled { background: var(--bg-primary); border: 1px solid rgba(0, 102, 255, 0.2); border-radius: 8px; - padding: 8px 12px; + height: 40px; + padding: 3px 12px; flex-shrink: 0; min-width: 280px; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03); @@ -7912,6 +8127,12 @@ html[data-theme="dark"] .login-card .login-submit:disabled { transform: translateY(-1px); } +@media (max-width: 768px) { + .active-tasks-bar { + padding: 10px 16px; + } +} + .active-task-info { display: flex; align-items: center; @@ -18112,7 +18333,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { flex-shrink: 0; } -.group-item:hover .group-item-menu { +.group-item:hover .group-item-menu, +.group-item:focus-within .group-item-menu { opacity: 1; } @@ -35507,7 +35729,7 @@ body.app-modal-open { } /* 对话区项目选择器(与角色/代理模式共用 role-selector-*) */ .project-selector-wrapper .role-selector-text { - max-width: 108px; + max-width: 13em; min-width: 0; flex-shrink: 1; overflow: hidden; @@ -36114,26 +36336,44 @@ html[data-theme="dark"] .progress-container.completed { border-color: #263244; } -html[data-theme="dark"] .chat-scroll-to-bottom { - background: rgba(15, 23, 42, 0.94); - color: #dbeafe; - border-color: rgba(96, 165, 250, 0.36); - box-shadow: - 0 10px 24px rgba(0, 0, 0, 0.34), - 0 0 0 1px rgba(255, 255, 255, 0.03) inset; +html[data-theme="dark"] .chat-turn-rail-marker::before { + background: #505866; } -html[data-theme="dark"] .chat-scroll-to-bottom:hover { +html[data-theme="dark"] .chat-turn-rail-marker:hover::before { + background: #a0a8b5; +} + +html[data-theme="dark"] .chat-turn-rail-marker.is-active::before { + background: #f4f7fb; +} + +html[data-theme="dark"] .chat-turn-rail-preview { + border-color: rgba(148, 163, 184, 0.24); + background: rgba(15, 23, 42, 0.97); + color: #f8fafc; + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); +} + +html[data-theme="dark"] .chat-turn-rail-preview-title { + color: #f8fafc; +} + +html[data-theme="dark"] .chat-turn-rail-preview-summary { + color: #9ba5b4; +} + +html[data-theme="dark"] .chat-return-latest { + border-color: rgba(148, 163, 184, 0.28); + background: rgba(15, 23, 42, 0.96); + color: #cbd5e1; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38), 0 1px 3px rgba(0, 0, 0, 0.28); +} + +html[data-theme="dark"] .chat-return-latest:hover { + border-color: rgba(148, 163, 184, 0.42); background: #172033; color: #ffffff; - border-color: rgba(147, 197, 253, 0.62); - box-shadow: - 0 12px 28px rgba(0, 0, 0, 0.42), - 0 0 0 1px rgba(147, 197, 253, 0.08) inset; -} - -html[data-theme="dark"] .chat-scroll-to-bottom:focus-visible { - outline-color: #93c5fd; } html[data-theme="dark"] .process-details-container { @@ -36301,10 +36541,10 @@ html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-ti color: #fbbf24; } -html[data-theme="dark"] .message.assistant .message-bubble { - background: #111827; +html[data-theme="dark"] .message.assistant:not(.progress-message):not(.assistant-turn-with-process) .message-bubble { + background: transparent; color: var(--text-primary); - border-color: var(--border-color); + border-color: transparent; } html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble { @@ -36346,35 +36586,11 @@ html[data-theme="dark"] .message-copy-btn:active { } html[data-theme="dark"] .message.user .message-bubble { - background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); - color: #ffffff; -} - -html[data-theme="dark"] .message-avatar { - border-radius: 50%; -} - -html[data-theme="dark"] .message.user .message-avatar { - background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); - color: #ffffff; - box-shadow: - 0 0 0 2px rgba(96, 165, 250, 0.22), - 0 2px 10px rgba(37, 99, 235, 0.38); - border: none; -} - -html[data-theme="dark"] .message.assistant .message-avatar { - background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); - color: var(--text-secondary); - border: 1px solid rgba(96, 165, 250, 0.28); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32); - padding: 3px; -} - -html[data-theme="dark"] .message.assistant .message-avatar-img { - width: 22px; - height: 22px; - border-radius: 50%; + background: #1b2638; + color: #e8eef8; + border-color: rgba(96, 165, 250, 0.18); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.025) inset, + 0 8px 24px rgba(2, 6, 23, 0.16); } html[data-theme="dark"] .message.system .message-bubble, @@ -36958,7 +37174,7 @@ html[data-theme="dark"] .chat-input-primary-row .chat-input-with-files { } html[data-theme="dark"] .chat-input-field, -html[data-theme="dark"] .chat-input-container textarea { +html[data-theme="dark"] .chat-input-container .chat-input-field > #chat-input { background: #0f172a !important; color: var(--text-primary) !important; border-color: #2b374b !important; @@ -44054,3 +44270,2649 @@ html[data-theme="dark"] #project-panel-assets .projects-panel-pagination.paginat background: #0f172a !important; border-color: #223047 !important; } + +/* -------------------------------------------------------------------------- + Project-centric conversation sidebar + The primary folder list reuses the real chat project selector data; groups + and recent conversations remain secondary navigation below it. + -------------------------------------------------------------------------- */ +.conversation-sidebar { + width: 320px; + background: #ffffff; +} + +.conversation-sidebar-header { + padding: 14px; + background: #ffffff; +} + +.conversation-sidebar .sidebar-content { + padding: 14px 14px 18px; +} + +.conversation-sidebar .conversation-search-box { + margin: 8px 0 16px; +} + +.conversation-sidebar .conversation-search-box input { + min-height: 42px; + padding-left: 36px; + border-color: #dfe3e8; + border-radius: 9px; + background: #ffffff; +} + +.conversation-sidebar .conversation-search-box::before { + content: ''; + position: absolute; + left: 12px; + width: 16px; + height: 16px; + z-index: 1; + pointer-events: none; + background: center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2377818f' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-4-4'/%3E%3C/svg%3E"); +} + +.conversation-sidebar .conversation-project-filter { + display: none !important; +} + +.project-folders-section { + margin-bottom: 16px; + min-width: 0; +} + +.project-folders-header { + min-height: 26px; + margin-bottom: 5px !important; +} + +.project-folders-list { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.project-folders-load-more { + width: calc(100% - 12px); + min-height: 34px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin: 5px 6px 1px; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: #657080; + font: inherit; + font-size: 0.8125rem; + font-weight: 560; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} + +.project-folders-load-more:hover, +.project-folders-load-more:focus-visible { + background: #f1f3f5; + color: #273142; + outline: none; +} + +.project-folders-load-more-count { + min-width: 20px; + padding: 1px 6px; + border-radius: 999px; + background: #e8ebef; + color: #697382; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + line-height: 1.35; +} + +.project-folder-row { + position: relative; + min-width: 0; +} + +.project-folder-item { + position: relative; + width: 100%; + min-height: 38px; + display: grid; + grid-template-columns: 15px 19px minmax(0, 1fr); + align-items: center; + padding: 6px 68px 6px 6px; + column-gap: 7px; + border: 0; + border-radius: 9px; + background: transparent; + color: #303846; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.project-folder-actions { + position: absolute; + top: 50%; + right: 5px; + display: flex; + align-items: center; + gap: 1px; + opacity: 0; + pointer-events: none; + transform: translateY(-50%); + transition: opacity 0.14s ease; +} + +.project-folder-row:hover .project-folder-actions, +.project-folder-row:focus-within .project-folder-actions { + opacity: 1; + pointer-events: auto; +} + +.project-folder-action, +.project-conversation-menu { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: #59616d; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; +} + +.project-folder-action:hover, +.project-folder-action:focus-visible, +.project-conversation-menu:hover, +.project-conversation-menu:focus-visible { + background: rgba(255, 255, 255, 0.86); + color: #1f2937; + box-shadow: 0 1px 2px rgba(21, 28, 38, 0.08); + outline: none; +} + +.project-folder-item:hover { + background: #f3f4f5; +} + +.project-folder-item:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.42); + outline-offset: -2px; +} + +.project-folder-item:disabled { + cursor: wait; + opacity: 0.72; +} + +.project-folder-disclosure { + width: 15px; + height: 15px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #727a85; +} + +.project-folder-icon { + width: 19px; + height: 19px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: #505965; +} + +.project-folder-icon.is-open { + color: #343b44; +} + +.project-folder-label, +.project-conversation-label { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.project-folder-title { + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 520; + line-height: 1.35; +} + +.project-folder-pinned { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + color: var(--accent-color); + font-size: 0.72rem; + line-height: 1; +} + +.project-folder-preview { + position: fixed; + z-index: 1200; + width: min(326px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid rgba(30, 41, 59, 0.16); + border-radius: 14px; + background: rgba(255, 255, 255, 0.985); + color: #20242a; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.14), 0 2px 7px rgba(15, 23, 42, 0.05); + backdrop-filter: blur(16px); +} + +.project-folder-preview[hidden] { + display: none !important; +} + +.project-folder-preview-header { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 14px 14px 8px; +} + +.project-folder-preview-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #252b33; +} + +.project-folder-preview-icon svg { + width: 18px; + height: 18px; +} + +.project-folder-preview-title { + min-width: 0; + overflow: hidden; + color: #171b20; + font-size: 0.98rem; + font-weight: 650; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-folder-preview-stats { + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px 11px; + color: #515862; + font-size: 0.82rem; + line-height: 1.4; + font-variant-numeric: tabular-nums; +} + +.project-folder-preview-stats svg, +.project-folder-preview-detail svg { + flex: 0 0 auto; + color: #89909a; +} + +.project-folder-preview-stats svg { + width: 15px; + height: 15px; +} + +.project-folder-preview-details { + padding: 3px 0; + border-top: 1px solid #e7e9ed; + border-bottom: 1px solid #e7e9ed; +} + +.project-folder-preview-detail { + display: grid; + grid-template-columns: 15px minmax(0, 1fr); + align-items: start; + gap: 8px; + padding: 8px 14px; + color: #4b525c; + font-size: 0.82rem; + line-height: 1.45; +} + +.project-folder-preview-detail[hidden] { + display: none !important; +} + +.project-folder-preview-detail svg { + width: 14px; + height: 14px; + margin-top: 2px; +} + +.project-folder-preview-detail span { + min-width: 0; + overflow-wrap: anywhere; +} + +.project-folder-preview-detail.is-empty { + color: #8a919b; +} + +.project-folder-preview-edit { + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 0; + background: transparent; + color: #2e343c; + font: inherit; + font-size: 0.84rem; + font-weight: 560; + text-align: left; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} + +.project-folder-preview-edit[hidden], +.project-folder-preview.is-unassigned .project-folder-preview-edit { + display: none !important; +} + +.project-folder-preview.is-unassigned .project-folder-preview-details { + border-bottom: 0; +} + +.project-folder-preview-edit svg { + width: 16px; + height: 16px; +} + +.project-folder-preview-edit:hover, +.project-folder-preview-edit:focus-visible { + background: #f1f3f5; + color: #171b20; + outline: none; +} + +html[data-theme="dark"] .project-folder-preview { + border-color: rgba(148, 163, 184, 0.24); + background: rgba(15, 23, 42, 0.985); + color: #f8fafc; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.44); +} + +html[data-theme="dark"] .project-folder-preview-icon, +html[data-theme="dark"] .project-folder-preview-title { + color: #f8fafc; +} + +html[data-theme="dark"] .project-folder-preview-stats, +html[data-theme="dark"] .project-folder-preview-detail, +html[data-theme="dark"] .project-folder-preview-edit { + color: #cbd5e1; +} + +html[data-theme="dark"] .project-folder-preview-details { + border-color: rgba(148, 163, 184, 0.2); +} + +html[data-theme="dark"] .project-folder-preview-edit:hover, +html[data-theme="dark"] .project-folder-preview-edit:focus-visible { + background: rgba(148, 163, 184, 0.13); + color: #ffffff; +} + +@media (max-width: 900px), (hover: none) { + .project-folder-preview, + .project-conversation-preview { + display: none !important; + } +} + +.project-conversation-row { + position: relative; + min-width: 0; + border-radius: 9px; +} + +.project-conversation-item { + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + padding: 7px 38px 7px 55px; + border: 0; + border-radius: 9px; + background: transparent; + color: #3f4650; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.project-conversation-menu { + position: absolute; + top: 50%; + right: 5px; + opacity: 0; + pointer-events: none; + transform: translateY(-50%); +} + +.project-conversation-row:hover .project-conversation-menu, +.project-conversation-row:focus-within .project-conversation-menu { + opacity: 1; + pointer-events: auto; +} + +.project-conversation-row:hover .project-conversation-item { + background: #f3f4f5; +} + +.project-conversation-item.is-selected { + background: #eef0f2; + color: #171b20; + box-shadow: none; +} + +.project-conversation-row:hover .project-conversation-item.is-selected { + background: #e8eaed; +} + +.project-conversation-item:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.42); + outline-offset: -2px; +} + +.project-conversation-title { + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; + font-weight: 500; + line-height: 1.35; +} + +.project-conversation-item.is-selected .project-conversation-title { + font-weight: 650; +} + +.project-conversation-preview { + position: fixed; + z-index: 1200; + width: min(300px, calc(100vw - 32px)); + padding: 12px 14px 11px; + border: 1px solid rgba(30, 41, 59, 0.15); + border-radius: 14px; + background: rgba(255, 255, 255, 0.985); + color: #20242a; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.13), 0 2px 6px rgba(15, 23, 42, 0.05); + backdrop-filter: blur(16px); + pointer-events: none; +} + +.project-conversation-preview[hidden] { + display: none !important; +} + +.project-conversation-preview-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 10px; + margin-bottom: 8px; +} + +.project-conversation-preview-title { + min-width: 0; + overflow: hidden; + color: #171b20; + font-size: 0.9rem; + font-weight: 650; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-conversation-preview-age { + color: #8b929c; + font-size: 0.76rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.project-conversation-preview-age[hidden] { + display: none !important; +} + +.project-conversation-preview-meta { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + min-height: 22px; + color: #343b44; + font-size: 0.82rem; + line-height: 1.35; +} + +.project-conversation-preview-meta svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + color: #858d98; +} + +.project-conversation-preview-project, +.project-conversation-preview-mode { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-conversation-preview-mode { + flex: 0 1 auto; +} + +.project-conversation-preview-separator { + color: #a0a6ae; +} + +.project-conversation-preview-status { + flex: 0 0 auto; + color: #7a818c; + font-size: 0.76rem; + white-space: nowrap; +} + +.project-conversation-preview-status.is-running, +.project-conversation-preview-status.is-unread { + color: #2563eb; +} + +html[data-theme="dark"] .project-conversation-preview { + border-color: rgba(148, 163, 184, 0.24); + background: rgba(15, 23, 42, 0.985); + color: #f8fafc; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42); +} + +html[data-theme="dark"] .project-conversation-preview-title { + color: #f8fafc; +} + +html[data-theme="dark"] .project-conversation-preview-meta { + color: #d7dee8; +} + +html[data-theme="dark"] .project-conversation-preview-status.is-running, +html[data-theme="dark"] .project-conversation-preview-status.is-unread { + color: #60a5fa; +} + +.project-task-status-group { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.project-task-status { + display: inline-block; + flex: 0 0 auto; + border-radius: 50%; +} + +.project-task-status--running { + width: 11px; + height: 11px; + border: 1.5px solid #2563eb; + border-top-color: rgba(37, 99, 235, 0.18); + animation: project-task-status-spin 0.9s linear infinite; +} + +.project-task-status--unread { + width: 6px; + height: 6px; + background: #2563eb; +} + +.project-task-status--approval { + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + width: auto; + min-height: 22px; + padding: 2px 8px 4px; + overflow: hidden; + border-radius: 999px; + background: #dcfce7; + color: #16804a; + font-size: 0.72rem; + font-weight: 650; + line-height: 18px; + white-space: nowrap; +} + +.project-approval-time { + font-size: 0.66rem; + font-variant-numeric: tabular-nums; + opacity: 0.78; +} + +.project-approval-progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: color-mix(in srgb, currentColor 16%, transparent); +} + +.project-approval-progress-value { + display: block; + width: 100%; + height: 100%; + background: currentColor; + transition: width 0.25s linear; +} + +.project-task-status--approval.is-expired { + background: #fee2e2; + color: #b42318; +} + +.project-task-status--approval-summary { + padding-bottom: 2px; + transition: background-color 0.25s ease, color 0.25s ease; +} + +.project-task-status--approval.is-urgency-normal { + background: #dcfce7; + color: #16804a; +} + +.project-task-status--approval.is-urgency-warning { + background: #fef9c3; + color: #9a6700; +} + +.project-task-status--approval.is-urgency-critical { + background: #fee2e2; + color: #b42318; +} + +html[data-theme="dark"] .project-task-status--running { + border-color: #60a5fa; + border-top-color: rgba(96, 165, 250, 0.2); +} + +html[data-theme="dark"] .project-task-status--unread { + background: #60a5fa; +} + +html[data-theme="dark"] .project-task-status--approval { + background: rgba(52, 211, 153, 0.16); + color: #6ee7b7; +} + +html[data-theme="dark"] .project-task-status--approval.is-expired { + background: rgba(248, 113, 113, 0.16); + color: #fca5a5; +} + +html[data-theme="dark"] .project-task-status--approval.is-urgency-normal { + background: rgba(52, 211, 153, 0.16); + color: #6ee7b7; +} + +html[data-theme="dark"] .project-task-status--approval.is-urgency-warning { + background: rgba(250, 204, 21, 0.17); + color: #fde047; +} + +html[data-theme="dark"] .project-task-status--approval.is-urgency-critical { + background: rgba(248, 113, 113, 0.16); + color: #fca5a5; +} + +.project-conversation-preview-status.is-approval, +html[data-theme="dark"] .project-conversation-preview-status.is-approval { + color: #22a06b; +} + +html[data-theme="dark"] .project-folders-load-more { + color: #94a3b8; +} + +html[data-theme="dark"] .project-folders-load-more:hover, +html[data-theme="dark"] .project-folders-load-more:focus-visible { + background: rgba(148, 163, 184, 0.1); + color: #d7dee9; +} + +html[data-theme="dark"] .project-folders-load-more-count { + background: rgba(148, 163, 184, 0.14); + color: #aeb9c9; +} + +@keyframes project-task-status-spin { + to { transform: rotate(360deg); } +} + +.project-folders-empty { + padding: 12px 8px; + color: #9aa1ad; + font-size: 0.8125rem; + text-align: center; +} + +.conversation-sidebar .conversation-groups-section, +.conversation-sidebar .recent-conversations-section { + margin: 0; + padding: 13px 0; + border-top: 1px solid #eceff3; +} + +.conversation-sidebar .conversation-groups-section .section-header { + margin-bottom: 5px; +} + +.conversation-sidebar .group-item { + min-height: 38px; + padding: 7px 8px; + border: 0; + border-radius: 8px; +} + +.conversation-sidebar .group-item.active { + background: #edf4ff; +} + +.recent-conversations-toggle { + width: 100%; + min-height: 30px; + margin: 0 !important; + padding: 0 8px !important; + border: 0; + border-radius: 7px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.recent-conversations-toggle:hover { + background: #f3f4f6; +} + +.recent-conversations-toggle-meta { + display: inline-flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; + color: #7c8592; +} + +.recent-conversations-count { + min-width: 1.5em; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.recent-conversations-chevron { + transition: transform 0.16s ease; +} + +.recent-conversations-toggle[aria-expanded="true"] .recent-conversations-chevron { + transform: rotate(90deg); +} + +.recent-conversations-body[hidden] { + display: none !important; +} + +.conversation-sidebar-pagination[hidden] { + display: none !important; +} + +.conversation-sidebar:has(.recent-conversations-section.is-collapsed) .conversation-sidebar-pagination { + display: none !important; +} + +.recent-conversations-actions { + justify-content: flex-end; + min-height: 28px; + margin: 5px 5px 3px; +} + +.recent-conversations-body .conversation-group-title { + padding-top: 7px; +} + +.recent-conversations-body .conversation-item { + padding: 8px; +} + +html[data-theme="dark"] .conversation-sidebar, +html[data-theme="dark"] .conversation-sidebar-header { + background: var(--bg-primary); +} + +html[data-theme="dark"] .conversation-sidebar .conversation-search-box input { + background: var(--bg-primary); + border-color: var(--border-color); +} + +html[data-theme="dark"] .project-folder-item, +html[data-theme="dark"] .project-conversation-item { + color: var(--text-primary); +} + +html[data-theme="dark"] .project-folder-item:hover, +html[data-theme="dark"] .project-conversation-item:hover, +html[data-theme="dark"] .project-conversation-row:hover .project-conversation-item, +html[data-theme="dark"] .recent-conversations-toggle:hover { + background: var(--bg-tertiary); +} + +html[data-theme="dark"] .project-folder-action:hover, +html[data-theme="dark"] .project-folder-action:focus-visible, +html[data-theme="dark"] .project-conversation-menu:hover, +html[data-theme="dark"] .project-conversation-menu:focus-visible { + background: rgba(71, 85, 105, 0.28); + color: var(--text-primary); + box-shadow: none; +} + +html[data-theme="dark"] .project-conversation-item.is-selected { + background: rgba(148, 163, 184, 0.13); + color: var(--text-primary); + box-shadow: none; +} + +html[data-theme="dark"] .project-conversation-row:hover .project-conversation-item.is-selected { + background: rgba(148, 163, 184, 0.19); +} + +html[data-theme="dark"] .conversation-sidebar .group-item.active { + background: rgba(59, 130, 246, 0.16); + color: #93c5fd; +} + +html[data-theme="dark"] .conversation-sidebar .conversation-groups-section, +html[data-theme="dark"] .conversation-sidebar .recent-conversations-section { + border-color: var(--border-color); +} + +@media (max-width: 900px) { + .conversation-sidebar { + width: 260px; + } + + .conversation-sidebar.collapsed { + width: 56px; + } +} + +/* ============================================================================ + Layered chat composer: Codex-style hierarchy with CyberStrikeAI controls + ============================================================================ */ + +.chat-input-container { + position: relative; + isolation: isolate; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + width: 100%; + padding: 0 22px 14px; + border-top: 0; + background: transparent; + box-sizing: border-box; + container-name: chat-composer; + container-type: inline-size; +} + +.chat-composer-context { + position: relative; + z-index: 1; + display: flex; + align-items: center; + min-height: 52px; + margin: 0 30px -9px; + padding: 0 15px 9px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-bottom: 0; + border-radius: 22px 22px 0 0; + background: #f5f6f7; + box-shadow: 0 -8px 28px rgba(15, 23, 42, 0.035); + box-sizing: border-box; +} + +.chat-composer-context .chat-input-leading { + align-items: center; + gap: 0; + min-width: 0; +} + +.chat-composer-context .chat-input-leading > * { + position: relative; + min-width: 0; +} + +.chat-composer-context .chat-input-leading > *:not(:last-child) { + margin-right: 4px; + padding-right: 5px; +} + +.chat-composer-context .chat-input-leading > *:not(:last-child)::after { + content: ''; + position: absolute; + top: 11px; + right: 0; + bottom: 11px; + width: 1px; + background: rgba(15, 23, 42, 0.09); +} + +.chat-composer-context .role-selector-btn { + height: 40px; + padding: 0 12px; + gap: 8px; + border: 0; + border-radius: 12px; + background: transparent; + box-shadow: none; + color: #20242a; + font-size: 14px; + font-weight: 560; +} + +.chat-composer-context .role-selector-btn:hover, +.chat-composer-context .role-selector-btn.active { + border: 0; + background: rgba(15, 23, 42, 0.055); + box-shadow: none; +} + +.chat-composer-context .role-selector-btn:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.38); + outline-offset: -2px; +} + +.chat-composer-context .role-selector-icon { + width: 20px; + min-width: 20px; + height: 20px; + font-size: 18px; +} + +.chat-composer-context .role-selector-icon svg { + display: block; +} + +.chat-composer-context .role-selector-text { + color: #20242a; + font-size: 14px; + font-weight: 560; +} + +.chat-composer-context .role-selector-arrow { + color: #737b87; +} + +.chat-composer-surface.chat-input-primary-row { + position: relative; + z-index: 2; + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: stretch; + gap: 0; + width: 100%; + min-width: 0; + min-height: 118px; + padding: 0; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 22px; + background: #fff; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.08), 0 2px 7px rgba(15, 23, 42, 0.05); + box-sizing: border-box; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.chat-composer-surface.chat-input-primary-row:focus-within { + border-color: rgba(0, 102, 255, 0.38); + box-shadow: 0 16px 38px rgba(15, 23, 42, 0.09), 0 0 0 3px rgba(0, 102, 255, 0.07); +} + +.chat-input-container .chat-composer-surface .chat-input-with-files { + flex: 1; + gap: 0; + width: 100%; + padding: 0; +} + +.chat-composer-surface .chat-file-list:not(:empty) { + padding: 18px 22px 0; +} + +.chat-composer-surface .chat-upload-progress-row:not([hidden]) { + margin: 12px 22px 0; +} + +.chat-composer-surface .chat-input-field { + min-height: 58px; +} + +.chat-input-container .chat-composer-surface .chat-input-field > #chat-input { + width: 100%; + height: 58px; + min-height: 58px; + max-height: 180px; + padding: 17px 22px 6px; + border: 0; + border-radius: 22px 22px 8px 8px; + background: transparent; + color: var(--text-primary); + font-family: inherit; + font-size: 15px; + font-weight: 450; + line-height: 1.55; + box-shadow: none; + resize: none; + box-sizing: border-box; +} + +.chat-input-container .chat-composer-surface .chat-input-field > #chat-input:focus { + border: 0; + background: transparent; + box-shadow: none; +} + +.chat-input-container .chat-composer-surface .chat-input-field > #chat-input::placeholder { + color: #a0a6af; + opacity: 1; +} + +.chat-composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 50px; + padding: 2px 12px 9px 14px; +} + +.chat-composer-footer-leading, +.chat-composer-footer-trailing { + display: flex; + align-items: center; + min-width: 0; +} + +.chat-composer-footer-leading { + gap: 5px; +} + +.chat-composer-footer-trailing { + justify-content: flex-end; + gap: 5px; +} + +.chat-input-container .chat-upload-btn { + width: 36px; + height: 36px; + border: 0; + border-radius: 10px; + background: transparent; + color: #31363d; + box-shadow: none; +} + +.chat-input-container .chat-upload-btn:hover { + border: 0; + background: rgba(15, 23, 42, 0.055); + color: #111827; +} + +.chat-session-shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-width: 0; + height: 36px; + padding: 0 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: #767d87; + font-family: inherit; + font-size: 13px; + font-weight: 520; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease; +} + +.chat-session-shortcut:hover { + background: rgba(15, 23, 42, 0.055); + color: #272c33; +} + +.chat-session-shortcut:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.35); + outline-offset: -2px; +} + +.chat-hitl-shortcut { + color: #777e88; +} + +.chat-hitl-shortcut > span { + display: block; + min-width: 0; + padding-block: 1px; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.chat-session-shortcut-chevron { + flex-shrink: 0; + color: #8f96a0; +} + +.chat-session-meta { + padding-right: 8px; + padding-left: 8px; + color: #666d77; + font-size: 13px; +} + +.chat-model-shortcut-wrap { + position: relative; + display: inline-flex; + align-items: center; + min-width: 0; +} + +.chat-model-shortcut-effort { + color: #9299a3; + font-weight: 470; +} + +.chat-system-model-caret { + flex: 0 0 auto; + transition: transform 0.16s ease; +} + +#chat-model-shortcut.active .chat-system-model-caret { + transform: rotate(180deg); +} + +.chat-system-model-menu { + position: absolute; + right: 0; + bottom: calc(100% + 10px); + z-index: 2200; + display: flex; + width: min(320px, calc(100vw - 32px)); + max-height: 320px; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-color, #dbe1ea); + border-radius: 14px; + background: var(--bg-primary, #fff); + box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18), 0 3px 10px rgba(15, 23, 42, 0.08); + animation: chat-system-model-menu-in 0.16s ease-out; +} + +.chat-system-model-menu[hidden] { + display: none; +} + +.chat-system-model-main[hidden], +.chat-system-model-subview[hidden] { + display: none; +} + +@keyframes chat-system-model-menu-in { + from { + opacity: 0; + transform: translateY(5px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.chat-system-model-menu-header { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + min-height: 52px; + align-items: center; + gap: 10px; + padding: 8px 12px 8px 8px; + border-bottom: 1px solid var(--border-color, #e5e7eb); + color: var(--text-primary, #111827); + font-size: 13px; +} + +.chat-system-model-main { + position: relative; + padding: 10px; +} + +.chat-system-model-setting-row { + display: flex; + width: 100%; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 9px 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--text-primary, #111827); + font-family: inherit; + cursor: pointer; +} + +.chat-system-model-setting-row:hover { + background: rgba(15, 23, 42, 0.055); +} + +.chat-system-model-setting-label { + font-size: 15px; + font-weight: 650; +} + +.chat-system-model-setting-value { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; + color: var(--text-secondary, #727984); + font-size: 14px; + font-weight: 500; +} + +.chat-system-model-setting-value > span { + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-system-model-back { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary, #727984); + cursor: pointer; +} + +.chat-system-model-back:hover { + background: rgba(15, 23, 42, 0.065); + color: var(--text-primary, #111827); +} + +.chat-system-model-subview { + display: flex; + min-height: 128px; + flex-direction: column; +} + +.chat-system-model-status { + min-width: 0; + overflow: hidden; + color: var(--text-muted, #8a929d); + font-size: 11px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-system-model-status[data-tone="success"] { + color: var(--success-color, #16a067); +} + +.chat-system-model-status[data-tone="error"] { + color: var(--error-color, #dc3545); +} + +.chat-system-model-status-live { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.chat-system-model-list { + min-height: 46px; + overflow-y: auto; + padding: 6px; + scrollbar-width: thin; +} + +.chat-system-model-option, +.chat-system-model-retry { + display: flex; + width: 100%; + min-height: 40px; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--text-primary, #111827); + font-family: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.chat-system-model-option:hover:not(:disabled), +.chat-system-model-option.is-selected, +.chat-system-model-retry:hover { + background: rgba(0, 102, 255, 0.08); +} + +.chat-system-model-option:disabled { + opacity: 0.55; + cursor: wait; +} + +.chat-system-model-option-label { + min-width: 0; + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-system-effort-option-label { + font-family: inherit; +} + +.chat-system-model-current { + flex: 0 0 auto; + padding: 3px 7px; + border-radius: 999px; + background: rgba(0, 102, 255, 0.12); + color: var(--accent-color, #0666ee); + font-size: 10px; + font-weight: 700; +} + +.chat-system-model-retry { + justify-content: center; + color: var(--accent-color, #0666ee); + font-weight: 650; +} + +.chat-composer-meta-divider { + width: 1px; + height: 18px; + margin: 0 1px; + background: rgba(15, 23, 42, 0.1); +} + +.chat-input-container .send-btn { + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 50%; + background: linear-gradient(145deg, #1b7aff 0%, #0666ee 100%); + box-shadow: 0 6px 15px rgba(0, 102, 255, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.chat-input-container .send-btn:hover { + background: linear-gradient(145deg, #126fe8 0%, #005bd8 100%); + box-shadow: 0 8px 18px rgba(0, 102, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +.chat-input-container .send-btn svg { + display: block; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + transform: translate(-50%, -50%); + transition: transform 0.18s ease; +} + +.chat-input-container .send-btn:hover svg { + transform: translate(-50%, -50%); +} + +.chat-input-container .send-btn:active svg { + transform: translate(-50%, -50%); +} + +.chat-input-container .send-btn-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +html[data-theme="dark"] .chat-input-container { + background: transparent !important; +} + +html[data-theme="dark"] .chat-composer-context { + border-color: #263244; + background: #172033; + box-shadow: 0 -8px 28px rgba(0, 0, 0, 0.15); +} + +html[data-theme="dark"] .chat-composer-context .role-selector-btn { + border: 0 !important; + background: transparent !important; + color: var(--text-primary); +} + +html[data-theme="dark"] .chat-composer-context .role-selector-btn:hover, +html[data-theme="dark"] .chat-composer-context .role-selector-btn.active { + border: 0 !important; + background: rgba(148, 163, 184, 0.1) !important; +} + +html[data-theme="dark"] .chat-composer-context .role-selector-text { + color: var(--text-primary); +} + +html[data-theme="dark"] .chat-composer-context .chat-input-leading > *:not(:last-child)::after, +html[data-theme="dark"] .chat-composer-meta-divider { + background: rgba(148, 163, 184, 0.18); +} + +html[data-theme="dark"] .chat-composer-surface.chat-input-primary-row { + border-color: #2b374b !important; + background: #0f172a !important; +} + +/* Keep the rounded surface as the sole dark background painter. Rectangular + child backgrounds otherwise cover the color exposed by its top corners. */ +html[data-theme="dark"] .chat-input-container .chat-composer-surface .chat-input-with-files, +html[data-theme="dark"] .chat-input-container .chat-composer-surface .chat-input-field { + background: transparent !important; +} + +html[data-theme="dark"] .chat-input-container .chat-composer-surface .chat-input-field > #chat-input { + border: 0 !important; + background: transparent !important; +} + +html[data-theme="dark"] .chat-input-container .chat-upload-btn { + border: 0 !important; + background: transparent !important; + color: var(--text-secondary); +} + +html[data-theme="dark"] .chat-input-container .chat-upload-btn:hover, +html[data-theme="dark"] .chat-session-shortcut:hover { + background: rgba(148, 163, 184, 0.11) !important; + color: var(--text-primary); +} + +html[data-theme="dark"] .chat-session-shortcut { + color: var(--text-secondary); +} + +html[data-theme="dark"] .chat-system-model-menu { + border-color: #334155; + background: #111a2b; + box-shadow: 0 20px 52px rgba(0, 0, 0, 0.45), 0 3px 10px rgba(0, 0, 0, 0.25); +} + +html[data-theme="dark"] .chat-system-model-menu-header { + border-color: #263244; +} + +html[data-theme="dark"] .chat-system-model-setting-row:hover, +html[data-theme="dark"] .chat-system-model-back:hover { + background: rgba(148, 163, 184, 0.11); +} + +html[data-theme="dark"] .chat-system-model-option:hover:not(:disabled), +html[data-theme="dark"] .chat-system-model-option.is-selected, +html[data-theme="dark"] .chat-system-model-retry:hover { + background: rgba(96, 165, 250, 0.12); +} + +@media (max-width: 900px) { + .chat-input-container { + padding: 0 12px 12px; + } + + .chat-composer-context { + min-height: 54px; + margin-right: 16px; + margin-left: 16px; + padding-right: 8px; + padding-left: 8px; + } + + .chat-composer-context .role-selector-btn { + padding-right: 9px; + padding-left: 9px; + } + + .chat-composer-context .project-selector-wrapper .role-selector-text { + max-width: 86px; + } + + .chat-composer-context #agent-mode-text { + max-width: 132px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +@media (max-width: 620px) { + .chat-composer-context .chat-input-leading > *:not(:last-child) { + margin-right: 1px; + padding-right: 2px; + } + + .chat-composer-context .role-selector-btn { + gap: 5px; + padding-right: 7px; + padding-left: 7px; + } + + .chat-composer-context .role-selector-text { + max-width: 82px; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-composer-footer { + gap: 6px; + padding-right: 10px; + padding-left: 10px; + } + + .chat-hitl-shortcut { + max-width: 142px; + padding-right: 7px; + padding-left: 7px; + } + + .chat-model-shortcut-wrap { + display: none; + } + + .chat-session-meta { + padding-right: 5px; + padding-left: 5px; + } +} + +@container chat-composer (max-width: 520px) { + .chat-composer-context { + margin-right: 8px; + margin-left: 8px; + padding-right: 7px; + padding-left: 7px; + } + + .chat-composer-context .chat-input-leading { + width: 100%; + } + + .chat-composer-context .role-selector-btn { + gap: 5px; + padding-right: 7px; + padding-left: 7px; + } + + .chat-composer-context #agent-mode-text { + display: none; + } + + .chat-composer-footer { + gap: 4px; + padding-right: 9px; + padding-left: 9px; + } + + .chat-hitl-shortcut { + max-width: 142px; + padding-right: 7px; + padding-left: 7px; + } + + .chat-model-shortcut-wrap { + display: none; + } + + .chat-session-meta { + padding-right: 5px; + padding-left: 5px; + } +} + +@container chat-composer (max-width: 350px) { + .chat-composer-context .project-selector-wrapper .role-selector-text, + .chat-composer-context #role-selector-text { + max-width: 62px; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-hitl-shortcut { + max-width: 118px; + } +} + +/* Composer-anchored session settings. The original controls are moved here at runtime + so AI channel, reasoning and approval configuration keep one shared source of truth. */ +.chat-composer-surface > .chat-session-settings-popover { + position: absolute; + z-index: 120; + left: 16px; + bottom: 60px; + width: min(360px, calc(100% - 32px)); + max-height: min(68vh, 620px); + padding: 14px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 18px; + background: rgba(255, 255, 255, 0.99); + box-shadow: 0 22px 54px rgba(15, 23, 42, 0.2), 0 4px 12px rgba(15, 23, 42, 0.08); + box-sizing: border-box; + transform: translateY(0); + transform-origin: left bottom; + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease; +} + +.chat-composer-surface > .chat-session-settings-popover.conversation-reasoning-collapsed { + display: block !important; + width: min(360px, calc(100% - 32px)); + height: auto; + min-height: 0; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(8px) scale(0.985); +} + +.chat-composer-surface > .chat-session-settings-popover .conversation-reasoning-card-header { + min-height: 40px; +} + +.chat-composer-surface > .chat-session-settings-popover .conversation-reasoning-body { + max-height: calc(min(68vh, 620px) - 62px); + padding: 8px 2px 2px; + overflow-x: hidden; + overflow-y: auto; +} + +.chat-composer-surface > .chat-session-settings-popover .conversation-reasoning-chevron { + transform: rotate(90deg); +} + +.chat-input-container .send-btn .send-btn-stop-icon { + position: absolute; + top: 50%; + left: 50%; + display: none; + width: 13px; + height: 13px; + border-radius: 2px; + background: #fff; + transform: translate(-50%, -50%); +} + +.chat-input-container .send-btn.is-task-running, +.chat-input-container .send-btn.is-task-running:hover, +.chat-input-container .send-btn.is-task-running:active { + background: #1f2227; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.chat-input-container .send-btn.is-task-running svg { + display: none; +} + +.chat-input-container .send-btn.is-task-running .send-btn-stop-icon { + display: block; +} + +html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover { + border-color: #334155; + background: rgba(15, 23, 42, 0.99); + box-shadow: 0 22px 54px rgba(0, 0, 0, 0.42), 0 4px 12px rgba(0, 0, 0, 0.24); +} + +@media (prefers-reduced-motion: reduce) { + .chat-composer-surface > .chat-session-settings-popover { + transition: none; + } +} + +/* Codex-style assistant turn: execution narrative first, durable answer second. */ +.message.assistant-turn-with-process, +.message.progress-message { + margin-bottom: 32px; +} + +.message.assistant-turn-with-process .message-content, +.message.progress-message .message-content { + flex-basis: min(920px, 100%); + max-width: min(86%, 920px); +} + +.message.assistant-turn-with-process .mcp-call-section { + order: 0; +} + +.message.assistant-turn-with-process .assistant-final-result { + order: 1; + width: 100%; + max-width: none; + margin-top: 18px; + padding: 0 0 2px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow-x: visible; + font-size: 0.975rem; + line-height: 1.72; +} + +.message.assistant-turn-with-process .message-time, +.message.assistant-turn-with-process .message-meta-footer { + order: 2; +} + +.message.assistant-turn-with-process .assistant-final-result > :first-child { + margin-top: 0; +} + +.message.assistant-turn-with-process .assistant-final-result > :last-child:not(.message-copy-btn) { + margin-bottom: 0; +} + +.message.assistant-turn-with-process .message-copy-btn { + right: 0; + bottom: -34px; + padding: 5px 9px; + border: 0; + background: transparent; + box-shadow: none; +} + +.message.assistant-turn-with-process .mcp-call-section { + width: 100%; + margin: 0; + padding: 0; + border: 0; +} + +.turn-process-summary.mcp-call-label, +.progress-summary-toggle.turn-process-summary { + appearance: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 44px; + margin: 0; + padding: 8px 2px 11px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 0; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 0.91rem; + font-weight: 500; + line-height: 1.35; + text-align: left; + cursor: pointer; +} + +.turn-process-summary.mcp-call-label::before { + display: none; +} + +.turn-process-summary:hover, +.turn-process-summary:focus-visible { + color: var(--text-secondary); +} + +.turn-process-summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-color) 58%, transparent); + outline-offset: 3px; + border-radius: 4px; +} + +.turn-process-leading { + display: inline-flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.turn-process-status-dot { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: #a8adb5; +} + +.turn-process-status-dot.is-running { + background: var(--accent-color, #0b6cff); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-color, #0b6cff) 10%, transparent); + animation: codex-turn-pulse 1.55s ease-in-out infinite; +} + +@keyframes codex-turn-pulse { + 0%, 100% { opacity: 0.55; transform: scale(0.88); } + 50% { opacity: 1; transform: scale(1); } +} + +.turn-process-chevron { + width: 18px; + height: 18px; + flex: 0 0 18px; + color: #a4a8ae; + transition: transform 0.18s ease; +} + +.turn-process-summary.is-expanded .turn-process-chevron { + transform: rotate(90deg); +} + +.message.assistant-turn-with-process .mcp-call-toolbar { + display: none; +} + +.message.assistant-turn-with-process .mcp-tool-list, +.message.assistant-turn-with-process .mcp-tool-list.expanded { + display: none; +} + +.message.assistant-turn-with-process .process-details-container { + margin: 0; + padding: 0; + border: 0; +} + +.message.assistant-turn-with-process .process-details-content .progress-timeline.expanded { + max-height: min(64vh, 720px); + margin-top: 4px; + padding-top: 8px; +} + +.message.progress-message .progress-container { + width: 100%; + max-width: none; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.message.progress-message .progress-header { + margin: 0; + padding: 0; + border: 0; +} + +.message.progress-message .progress-actions, +.message.progress-message .progress-footer { + display: none; +} + +.message.progress-message .progress-stage { + margin: 10px 0 3px 24px; + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 500; + line-height: 1.5; +} + +.message.progress-message .progress-timeline.expanded { + max-height: min(64vh, 720px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: auto; + scrollbar-gutter: stable; +} + +/* Open-document execution timeline. */ +.message.assistant-turn-with-process .progress-timeline, +.message.progress-message .progress-timeline { + position: relative; + padding: 5px 0 7px 22px; +} + +.message.assistant-turn-with-process .progress-timeline::before, +.message.progress-message .progress-timeline::before { + content: ""; + position: absolute; + top: 11px; + bottom: 12px; + left: 7px; + width: 1px; + background: color-mix(in srgb, var(--border-color) 82%, transparent); +} + +.message.assistant-turn-with-process .timeline-item, +.message.progress-message .timeline-item { + position: relative; + margin: 0; + padding: 7px 0 7px 13px !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + content-visibility: auto; + contain-intrinsic-size: auto 38px; +} + +.message.assistant-turn-with-process .timeline-item::before, +.message.progress-message .timeline-item::before { + content: ""; + position: absolute; + top: 16px; + left: -18px; + width: 7px; + height: 7px; + border: 2px solid var(--bg-primary); + border-radius: 50%; + background: #a7adb5; + box-sizing: border-box; + z-index: 1; +} + +.message.assistant-turn-with-process .timeline-item-tool_call::before, +.message.progress-message .timeline-item-tool_call::before { + background: var(--accent-color, #0b6cff); +} + +.message.assistant-turn-with-process .timeline-item-tool_call.tool-call-completed::before, +.message.progress-message .timeline-item-tool_call.tool-call-completed::before { + background: var(--success-color, #36a854); +} + +.message.assistant-turn-with-process .timeline-item-tool_call.tool-call-failed::before, +.message.progress-message .timeline-item-tool_call.tool-call-failed::before, +.message.assistant-turn-with-process .timeline-item-error::before, +.message.progress-message .timeline-item-error::before { + background: var(--error-color, #dc3545); +} + +.message.assistant-turn-with-process .timeline-item-header, +.message.progress-message .timeline-item-header { + min-height: 24px; + margin: 0; + gap: 8px; +} + +.message.assistant-turn-with-process .timeline-item-time, +.message.progress-message .timeline-item-time { + display: none; +} + +.message.assistant-turn-with-process .timeline-item-title, +.message.progress-message .timeline-item-title { + color: var(--text-secondary); + font-size: 0.86rem; + font-weight: 500; + line-height: 1.55; +} + +.message.assistant-turn-with-process .timeline-item-iteration .timeline-item-title, +.message.progress-message .timeline-item-iteration .timeline-item-title { + color: var(--text-primary); + font-weight: 600; +} + +/* 主代理轮次是一次新的“模型决策 → 工具结果 → 继续决策”边界。 + * 用轻量横线把长执行过程分组;子代理步骤仍沿用普通时间线节点。 */ +.message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider, +.message.progress-message .timeline-item-iteration.timeline-iteration-divider { + display: flex; + align-items: center; + gap: 12px; + margin-top: 10px; + padding-top: 12px !important; + padding-bottom: 8px !important; + overflow: visible; + content-visibility: visible; +} + +.message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider::after, +.message.progress-message .timeline-item-iteration.timeline-iteration-divider::after { + content: ""; + flex: 1 1 48px; + min-width: 28px; + height: 1px; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--border-color) 88%, transparent), + color-mix(in srgb, var(--border-color) 20%, transparent) + ); +} + +.message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider .timeline-item-header, +.message.progress-message .timeline-item-iteration.timeline-iteration-divider .timeline-item-header { + flex: 0 1 auto; + min-width: 0; + max-width: calc(100% - 40px); +} + +.message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider .timeline-item-title, +.message.progress-message .timeline-item-iteration.timeline-iteration-divider .timeline-item-title { + letter-spacing: 0.01em; +} + +@media (max-width: 768px) { + .message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider, + .message.progress-message .timeline-item-iteration.timeline-iteration-divider { + gap: 8px; + margin-top: 7px; + } + + .message.assistant-turn-with-process .timeline-item-iteration.timeline-iteration-divider::after, + .message.progress-message .timeline-item-iteration.timeline-iteration-divider::after { + flex-basis: 24px; + min-width: 16px; + } +} + +.message.assistant-turn-with-process .timeline-item-content, +.message.progress-message .timeline-item-content { + margin: 5px 0 2px; + padding: 0; + border: 0; + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.6; +} + +.message.assistant-turn-with-process .timeline-item.tool-detail-collapsible .timeline-item-header::after, +.message.progress-message .timeline-item.tool-detail-collapsible .timeline-item-header::after { + content: attr(data-tool-detail-label); + margin-left: auto; + padding-left: 12px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; +} + +.message.assistant-turn-with-process .tool-call-detail-content, +.message.progress-message .tool-call-detail-content { + margin: 7px 0 4px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 68%, transparent); +} + +.message.assistant-turn-with-process .tool-args, +.message.assistant-turn-with-process .tool-result, +.message.progress-message .tool-args, +.message.progress-message .tool-result { + border-color: color-mix(in srgb, var(--border-color) 82%, transparent); + background: var(--bg-primary); + font-size: 0.77rem; +} + +/* Codex-style inline approval and audit state. */ +.hitl-inline-approval.hitl-tool-approval-summary, +.hitl-inline-approval.hitl-inline-approval--merged { + margin: 5px 0 4px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: visible; +} + +.hitl-inline-approval.hitl-tool-approval-summary::before, +.hitl-inline-approval.hitl-inline-approval--merged::before { + display: none; +} + +.hitl-codex-tool-row, +.hitl-codex-state { + display: flex; + align-items: center; + gap: 8px; + min-height: 27px; + color: var(--text-secondary); + font-size: 0.86rem; + line-height: 1.45; +} + +.hitl-codex-tool-row { + color: var(--text-primary); + font-weight: 500; +} + +.hitl-codex-shield { + width: 18px; + height: 18px; + flex: 0 0 18px; + color: #858a91; +} + +.hitl-codex-state strong { + color: inherit; + font-weight: 500; +} + +.hitl-codex-state--running, +.hitl-codex-state--pending { + color: var(--text-secondary); +} + +.hitl-codex-state--approved { + color: #2f8a45; +} + +.hitl-codex-state--rejected { + color: var(--error-color, #c73b43); +} + +.hitl-codex-state--interrupted { + color: var(--text-muted); +} + +.hitl-codex-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + flex: 0 0 17px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 0.72rem; + font-weight: 700; + line-height: 1; +} + +.hitl-codex-spinner { + width: 14px; + height: 14px; + flex: 0 0 14px; + border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: hitl-codex-spin 0.85s linear infinite; +} + +@keyframes hitl-codex-spin { + to { transform: rotate(360deg); } +} + +.hitl-codex-explainer { + margin: 2px 0 6px 25px; + color: var(--text-muted); + font-size: 0.82rem; + line-height: 1.52; +} + +.hitl-approval-heading { + margin: 8px 0 0 25px; +} + +.hitl-approval-heading h3 { + margin: 3px 0 0; + color: var(--text-primary); + font-size: 0.94rem; + font-weight: 600; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.hitl-approval-eyebrow { + color: #22a06b; + font-size: 0.75rem; + font-weight: 650; +} + +.hitl-approval-primary { + margin: 8px 0 0 25px; +} + +.hitl-approval-primary code { + display: block; + max-height: 112px; + padding: 9px 11px; + overflow: auto; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + color: var(--text-primary); + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.hitl-approval-countdown { + margin: 10px 0 0 25px; +} + +.hitl-approval-countdown--unlimited { + color: var(--text-muted); + font-size: 0.76rem; +} + +.hitl-approval-countdown--interrupted { + color: var(--text-muted); +} + +.hitl-approval-countdown--interrupted .hitl-codex-state { + min-height: 24px; + font-size: 0.78rem; +} + +.hitl-approval-interrupted .hitl-approval-eyebrow { + color: var(--text-muted); +} + +.hitl-approval-countdown-copy { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text-muted); + font-size: 0.76rem; +} + +.hitl-approval-countdown-copy strong { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.hitl-approval-progress { + height: 3px; + margin-top: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--border-color) 68%, transparent); +} + +.hitl-approval-progress-value { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2563eb, #60a5fa); + transition: width 0.25s linear, background-color 0.2s ease; +} + +.hitl-approval-expired .hitl-approval-progress-value { + background: var(--error-color, #dc2626); +} + +.hitl-approval-details { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.hitl-approval-details + .hitl-approval-details { + margin-top: 6px; +} + +.hitl-approval-details summary { + width: max-content; + max-width: 100%; + cursor: pointer; + color: var(--text-secondary); + font-weight: 520; +} + +.hitl-approval-details pre, +.hitl-approval-details .hitl-inline-edit, +.hitl-approval-details .hitl-inline-comment { + width: 100%; + margin: 7px 0 0; + box-sizing: border-box; +} + +.hitl-approval-details pre { + max-height: 190px; + padding: 10px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.75rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.hitl-inline-actions kbd { + margin-left: 5px; + padding: 1px 5px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 5px; + background: color-mix(in srgb, currentColor 6%, transparent); + color: inherit; + font: inherit; + font-size: 0.72em; + opacity: 0.76; +} + +.chat-hitl-approval-dock[hidden] { + display: none !important; +} + +.chat-input-container.has-hitl-approval .chat-composer-context, +.chat-input-container.has-hitl-approval .chat-composer-surface { + display: none; +} + +.chat-hitl-approval-dock { + position: relative; + z-index: 4; + display: block; + width: 100%; + min-height: 158px; + padding: 18px 20px 16px; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 24px; + background: rgba(255, 255, 255, 0.985); + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.11), 0 2px 8px rgba(15, 23, 42, 0.05); + box-sizing: border-box; + outline: none; +} + +.chat-hitl-approval-dock .hitl-codex-tool-row { + font-size: 0.92rem; +} + +.chat-hitl-approval-dock .hitl-approval-heading, +.chat-hitl-approval-dock .hitl-approval-primary, +.chat-hitl-approval-dock .hitl-approval-countdown, +.chat-hitl-approval-dock .hitl-inline-body, +.chat-hitl-approval-dock .hitl-inline-actions { + margin-left: 26px; +} + +.chat-hitl-approval-dock .hitl-approval-heading h3 { + max-width: 900px; + font-size: 1rem; +} + +.chat-hitl-approval-dock .hitl-inline-body { + gap: 6px; + margin-top: 10px; + padding: 0; +} + +.chat-hitl-approval-dock .hitl-inline-actions { + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding: 0; + border: 0; + background: transparent; +} + +.chat-hitl-approval-dock .hitl-inline-status { + margin-right: auto; +} + +.chat-hitl-approval-dock .hitl-inline-approve, +.chat-hitl-approval-dock .hitl-inline-reject { + min-height: 38px; + padding: 8px 14px; + border-radius: 999px; +} + +:is(.hitl-inline-approval, .chat-hitl-approval-dock).hitl-approval-task-closed .hitl-pending-actions :is(button, .btn-primary, .btn-secondary) { + border-color: #d1d5db !important; + background: #e5e7eb !important; + color: #9ca3af !important; + box-shadow: none !important; + cursor: not-allowed; + opacity: 1; +} + +html[data-theme="dark"] :is(.hitl-inline-approval, .chat-hitl-approval-dock).hitl-approval-task-closed .hitl-pending-actions :is(button, .btn-primary, .btn-secondary) { + border-color: #374151 !important; + background: #273244 !important; + color: #7f8b9d !important; +} + +html[data-theme="dark"] .chat-hitl-approval-dock { + border-color: color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 97%, transparent); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); +} + +html[data-theme="dark"] .chat-hitl-approval-dock .hitl-inline-actions { + border-color: transparent !important; + background: transparent !important; +} + +html[data-theme="dark"] .hitl-approval-primary code, +html[data-theme="dark"] .hitl-approval-details pre { + border-color: rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.5); +} + +.hitl-codex-diff { + margin: 4px 0 4px 25px; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.hitl-codex-diff summary { + width: max-content; + cursor: pointer; +} + +.hitl-codex-diff pre { + max-height: 220px; + margin: 8px 0 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.76rem; +} + +.hitl-tool-approval-summary .hitl-inline-body { + gap: 8px; + margin: 8px 0 0 25px; + padding: 0; +} + +.hitl-tool-approval-summary .hitl-inline-actions { + justify-content: flex-end; + gap: 7px; + margin: 9px 0 0 25px; + padding: 9px 0 0; + border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + background: transparent; +} + +.hitl-tool-approval-summary .hitl-inline-actions .btn-primary, +.hitl-tool-approval-summary .hitl-inline-actions .btn-secondary { + min-width: 0; + min-height: 30px; + padding: 5px 11px; + border-radius: 7px; + font-size: 0.79rem; +} + +.hitl-tool-approval-summary .hitl-inline-status { + margin-right: auto; +} + +.hitl-tool-approval-summary .hitl-config-input, +.hitl-tool-approval-summary .hitl-edit-args { + border-radius: 7px; + background: var(--bg-primary); +} + +html[data-theme="dark"] .message.assistant-turn-with-process .timeline-item::before, +html[data-theme="dark"] .message.progress-message .timeline-item::before { + border-color: var(--bg-primary); +} + +html[data-theme="dark"] .hitl-inline-approval.hitl-tool-approval-summary, +html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged { + border: 0; + background: transparent; + box-shadow: none; +} + +@media (max-width: 760px) { + .message.assistant-turn-with-process, + .message.progress-message { + gap: 8px; + } + + .message.assistant-turn-with-process .message-content, + .message.progress-message .message-content { + flex-basis: calc(100% - 40px); + max-width: calc(100% - 40px); + } + + .message.assistant-turn-with-process .assistant-final-result { + font-size: 0.93rem; + } + + .message.assistant-turn-with-process .progress-timeline, + .message.progress-message .progress-timeline { + padding-left: 18px; + } + + .message.assistant-turn-with-process .timeline-item, + .message.progress-message .timeline-item { + padding-left: 9px !important; + } + + .message.assistant-turn-with-process .timeline-item.tool-detail-collapsible .timeline-item-header::after, + .message.progress-message .timeline-item.tool-detail-collapsible .timeline-item-header::after { + content: "›"; + font-size: 1rem; + } + + .hitl-codex-explainer, + .hitl-approval-heading, + .hitl-approval-primary, + .hitl-approval-countdown, + .hitl-tool-approval-summary .hitl-inline-body, + .hitl-tool-approval-summary .hitl-inline-actions, + .hitl-codex-diff { + margin-left: 0; + } + + .hitl-tool-approval-summary .hitl-inline-actions { + flex-wrap: wrap; + } + + .chat-hitl-approval-dock { + padding: 16px; + border-radius: 18px; + } + + .chat-hitl-approval-dock .hitl-approval-heading, + .chat-hitl-approval-dock .hitl-approval-primary, + .chat-hitl-approval-dock .hitl-approval-countdown, + .chat-hitl-approval-dock .hitl-inline-body, + .chat-hitl-approval-dock .hitl-inline-actions { + margin-left: 0; + } + + .chat-hitl-approval-dock .hitl-inline-actions { + flex-wrap: wrap; + } + + .chat-hitl-approval-dock .hitl-inline-status { + flex-basis: 100%; + min-height: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .project-task-status--running, + .turn-process-status-dot.is-running, + .hitl-codex-spinner { + animation: none; + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 9c9bf687..53eb9bcb 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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 3–5 minutes", + "approvalUrgencyOneToThree": "Earliest approval expires in 1–3 minutes", + "approvalUrgencyWithinOne": "Earliest approval expires within 1 minute", + "requestGeneric": "Allow CyberStrikeAI to call {{tool}}?", + "requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?", + "requestBrowser": "Allow CyberStrikeAI to use the browser?", + "requestCommand": "Allow CyberStrikeAI to run this command?", + "requestFile": "Allow CyberStrikeAI to modify {{path}}?", + "requestFiles": "Allow CyberStrikeAI to modify files?", + "toolTerminal": "Terminal", + "toolFiles": "Files", + "viewRequestDetails": "View request details", + "editRequestDetails": "View or edit request parameters", + "addApprovalComment": "Add approval comment (optional)", + "timeoutAutoReject": "Automatically rejects at expiry", + "timeoutUnlimited": "No time limit", + "expiredAutoRejected": "Approval timed out; rejecting automatically…", + "taskClosedApprovalUnavailable": "Task ended; approval is unavailable", + "taskInterrupted": "Task interrupted", + "interruptedApprovalCancelled": "Task interrupted; approval cancelled", + "expiredRejected": "Approval timed out and was rejected", "searchLabel": "Search", "searchPlaceholder": "Tool, conversation, payload, comment…", "searchApply": "Search", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 4e7b5de8..86c47a85 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": "搜索", diff --git a/web/static/js/auth.js b/web/static/js/auth.js index bfdad6b5..dc249656 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -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() { diff --git a/web/static/js/chat-codex-layout.test.cjs b/web/static/js/chat-codex-layout.test.cjs new file mode 100644 index 00000000..d2d22d8c --- /dev/null +++ b/web/static/js/chat-codex-layout.test.cjs @@ -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'); +}); diff --git a/web/static/js/chat-input-keyboard.test.cjs b/web/static/js/chat-input-keyboard.test.cjs new file mode 100644 index 00000000..f416563b --- /dev/null +++ b/web/static/js/chat-input-keyboard.test.cjs @@ -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); +}); diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs new file mode 100644 index 00000000..60b7ca70 --- /dev/null +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -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/); +}); diff --git a/web/static/js/chat-scroll.js b/web/static/js/chat-scroll.js index 5a110538..a79ecad8 100644 --- a/web/static/js/chat-scroll.js +++ b/web/static/js/chat-scroll.js @@ -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; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 12da72f1..f98d8f20 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1,5 +1,17 @@ let currentConversationId = null; + +/** Persist the visible chat in the URL so a reload can restore and reconnect it. */ +function syncChatConversationHash(conversationId) { + const normalizedConversationId = String(conversationId || '').trim(); + if (!normalizedConversationId || window.location.hash.split('?')[0] !== '#chat') return; + const targetHash = '#chat?conversation=' + encodeURIComponent(normalizedConversationId); + if (window.location.hash !== targetHash) { + window.history.replaceState(null, '', targetHash); + } +} +window.syncChatConversationHash = syncChatConversationHash; let loadConversationRequestSeq = 0; +let loadConversationAbortController = null; /** * 轻量会话 LRU 缓存。 @@ -60,6 +72,7 @@ let compositionEndTimer = null; // 输入框草稿保存相关 const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft'; +const RECENT_CONVERSATIONS_EXPANDED_KEY = 'cyberstrike-chat-recent-conversations-expanded'; let draftSaveTimer = null; const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟 @@ -91,6 +104,13 @@ let multiAgentAPIEnabled = false; let chatAIChannels = {}; let chatDefaultAIChannel = ''; let chatAIChannelIdByNormalizedId = {}; +let chatHitlAuditModelName = ''; +let chatSystemModelRequestSeq = 0; +let chatSystemModelSaving = false; +let chatSystemModelCloseTimer = null; +let chatSystemModelOptions = []; +let chatSystemModelCurrent = ''; +let chatSystemModelLoadError = ''; // 人机协同(HITL)会话级配置 const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl'; @@ -101,6 +121,11 @@ const HITL_MODE_OFF = 'off'; const HITL_MODE_APPROVAL = 'approval'; const HITL_MODE_REVIEW_EDIT = 'review_edit'; const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT]; +const DEFAULT_HITL_TIMEOUT_SECONDS = 300; +// Agent orchestration/control tools are safe baseline exemptions for every +// conversation. Keep this separate from config.tool_whitelist: the latter is +// enforced globally by the backend and must not be copied into this field. +const DEFAULT_HITL_SESSION_TOOL_WHITELIST = 'tool_search, skill, task, write_todos, transfer_to_agent, exit, TaskCreate, TaskGet, TaskUpdate, TaskList, upsert_project_fact, get_project_fact'; let hitlApplyFeedbackTimer = null; let hitlAutoSaveTimer = null; const sessionSettingsSelects = new Map(); @@ -268,16 +293,31 @@ function refreshSessionSettingsSelects() { } function syncChatReasoningBarHeight() { - const inputBar = document.getElementById('chat-input-container'); const reasoning = document.getElementById('chat-reasoning-wrapper'); - if (!inputBar || !reasoning) return; - const h = Math.ceil(inputBar.getBoundingClientRect().height || 0); - if (h > 0) { - reasoning.style.setProperty('--chat-input-bar-height', h + 'px'); + const inputBar = document.getElementById('chat-input-container'); + if (!reasoning || !inputBar) return; + // The composer is now a two-layer surface and is intentionally taller than + // the sidebar trigger. Do not mirror its height into the settings card. + reasoning.style.removeProperty('--chat-input-bar-height'); + const chatContainer = inputBar.closest('.chat-container'); + const height = Math.ceil(inputBar.getBoundingClientRect().height || 0); + if (chatContainer && height > 0) { + chatContainer.style.setProperty('--chat-composer-total-height', height + 'px'); } } +function mountChatSessionSettingsPopover() { + const wrap = document.getElementById('chat-reasoning-wrapper'); + const composerSurface = document.querySelector('.chat-composer-surface'); + if (!wrap || !composerSurface) return; + if (wrap.parentElement !== composerSurface) { + composerSurface.appendChild(wrap); + } + wrap.classList.add('chat-session-settings-popover'); +} + function initChatReasoningBarHeightSync() { + mountChatSessionSettingsPopover(); syncChatReasoningBarHeight(); window.addEventListener('resize', syncChatReasoningBarHeight); const inputBar = document.getElementById('chat-input-container'); @@ -333,6 +373,12 @@ function normalizeHitlMode(mode) { return HITL_MODE_OFF; } +function normalizeHitlTimeoutForChat(value, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(0, Math.min(86400, Math.round(n))); +} + function defaultHitlConfig() { const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer) ? window.csaiHitlDefaultReviewer @@ -340,7 +386,8 @@ function defaultHitlConfig() { return { mode: HITL_MODE_OFF, reviewer: normalizeHitlReviewer(serverReviewer), - sensitiveTools: '', + sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST, + timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS, updatedAt: '' }; } @@ -401,19 +448,24 @@ function getHitlStorageKeyByConversation(conversationId) { return `${HITL_STORAGE_PREFIX}:${String(conversationId || '').trim()}`; } +function chatTranslate(key, fallback) { + if (typeof window.t === 'function') { + const translated = window.t(key); + if (translated && translated !== key) return translated; + } + return fallback; +} + function getHitlModeLabel(mode) { const safeMode = normalizeHitlMode(mode); - if (typeof window.t === 'function') { - switch (safeMode) { - case HITL_MODE_APPROVAL: - return window.t('chat.hitlModeApproval'); - case HITL_MODE_REVIEW_EDIT: - return window.t('chat.hitlModeReviewEdit'); - default: - return window.t('chat.hitlModeOff'); - } + switch (safeMode) { + case HITL_MODE_APPROVAL: + return chatTranslate('chat.hitlModeApproval', '审批模式'); + case HITL_MODE_REVIEW_EDIT: + return chatTranslate('chat.hitlModeReviewEdit', '审查编辑'); + default: + return chatTranslate('chat.hitlModeOff', '关闭'); } - return safeMode; } function getHitlLastGlobalConfig() { @@ -427,6 +479,7 @@ function getHitlLastGlobalConfig() { mode: normalizeHitlMode(parsed.mode), reviewer: normalizeHitlReviewer(parsed.reviewer), sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds), updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' }; } catch (e) { @@ -458,6 +511,7 @@ function getHitlConfigForConversation(conversationId) { mode: normalizeHitlMode(parsed.mode), reviewer: normalizeHitlReviewer(parsed.reviewer), sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds), updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' }; } @@ -469,6 +523,7 @@ function getHitlConfigForConversation(conversationId) { mode: normalizeHitlMode(globalLast.mode), reviewer: normalizeHitlReviewer(globalLast.reviewer), sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(globalLast.timeoutSeconds, fallback.timeoutSeconds), updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : '' } : null; if (!draftCfg && !g) return fallback; @@ -482,20 +537,21 @@ function getHitlConfigForConversation(conversationId) { try { const raw = localStorage.getItem(key); if (!raw) { - return getHitlLastGlobalConfig() || fallback; + return fallback; } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') { - return getHitlLastGlobalConfig() || fallback; + return fallback; } return { mode: normalizeHitlMode(parsed.mode), reviewer: normalizeHitlReviewer(parsed.reviewer), sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds), updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' }; } catch (e) { - return getHitlLastGlobalConfig() || fallback; + return fallback; } } @@ -512,6 +568,7 @@ function setHitlReviewerUI(reviewer) { async function onHitlReviewerChanged(reviewer) { setHitlReviewerUI(reviewer); + updateChatReasoningSummary(); const cfg = readHitlConfigFromForm(); const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : ''; saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true }); @@ -548,6 +605,7 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) { mode: normalizeHitlMode(cfg && cfg.mode), reviewer: normalizeHitlReviewer(cfg && cfg.reviewer), sensitiveTools: typeof (cfg && cfg.sensitiveTools) === 'string' ? cfg.sensitiveTools : '', + timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS), updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : '' }; const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY; @@ -565,6 +623,7 @@ function readHitlConfigFromForm() { const modeEl = document.getElementById('hitl-mode-select'); const reviewerEl = document.getElementById('hitl-reviewer-select'); const toolsEl = document.getElementById('hitl-sensitive-tools'); + const timeoutEl = document.getElementById('hitl-timeout-select'); const mode = normalizeHitlMode(modeEl ? modeEl.value : HITL_MODE_OFF); const reviewer = normalizeHitlReviewer(reviewerEl ? reviewerEl.value : 'human'); let sensitiveTools = toolsEl ? String(toolsEl.value || '').trim() : ''; @@ -576,41 +635,57 @@ function readHitlConfigFromForm() { mode, reviewer, sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(timeoutEl ? timeoutEl.value : DEFAULT_HITL_TIMEOUT_SECONDS, DEFAULT_HITL_TIMEOUT_SECONDS), updatedAt: new Date().toISOString() }; } function updateHitlStatusUI(_cfg) { - /* 侧栏已改为自动保存,不再用角标展示模式 */ + /* 侧栏已改为自动保存;同步更新输入框快捷摘要。 */ + updateChatReasoningSummary(); } function applyHitlConfigToUI(cfg) { const conf = cfg || defaultHitlConfig(); const modeEl = document.getElementById('hitl-mode-select'); const toolsEl = document.getElementById('hitl-sensitive-tools'); + const timeoutEl = document.getElementById('hitl-timeout-select'); const uiMode = normalizeHitlMode(conf.mode); if (modeEl) modeEl.value = uiMode; setHitlReviewerUI(conf.reviewer); - let toolsVal = conf.sensitiveTools || ''; - const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null; - if (Array.isArray(g) && g.length > 0) { - const sessionArr = hitlToolsSplitToArray(toolsVal); - toolsVal = hitlMergeToolsForDisplay(g, sessionArr); + // Keep this field scoped to the current conversation. The config-level + // allowlist is applied by the backend and must not be copied into the + // editable session value. Empty/legacy sessions receive only the stable + // Agent control-tool baseline shown by the original UI. + const toolsVal = typeof conf.sensitiveTools === 'string' && conf.sensitiveTools.trim() + ? conf.sensitiveTools.trim() + : DEFAULT_HITL_SESSION_TOOL_WHITELIST; + if (toolsEl) { + toolsEl.value = toolsVal; + } + if (timeoutEl) { + const timeoutSeconds = normalizeHitlTimeoutForChat(conf.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS); + const supported = Array.from(timeoutEl.options || []).some(function (option) { + return Number(option.value) === timeoutSeconds; + }); + timeoutEl.value = String(supported ? timeoutSeconds : DEFAULT_HITL_TIMEOUT_SECONDS); } - if (toolsEl) toolsEl.value = toolsVal; updateHitlStatusUI(conf); refreshSessionSettingsSelects(); } function bindHitlSidebarModeListener() { const modeEl = document.getElementById('hitl-mode-select'); - if (!modeEl || modeEl.dataset.hitlModeBound === '1') return; - modeEl.dataset.hitlModeBound = '1'; - modeEl.addEventListener('change', function () { - applyHitlConfigToUI(readHitlConfigFromForm()); - refreshSessionSettingsSelects(); - scheduleHitlSidebarAutosave(0); - updateChatReasoningSummary(); + const timeoutEl = document.getElementById('hitl-timeout-select'); + [modeEl, timeoutEl].forEach(function (el) { + if (!el || el.dataset.hitlModeBound === '1') return; + el.dataset.hitlModeBound = '1'; + el.addEventListener('change', function () { + applyHitlConfigToUI(readHitlConfigFromForm()); + refreshSessionSettingsSelects(); + scheduleHitlSidebarAutosave(0); + updateChatReasoningSummary(); + }); }); } @@ -887,6 +962,7 @@ function syncAgentModeFromValue(value) { } function syncReasoningRowVisibility(modeVal) { + mountChatSessionSettingsPopover(); const wrap = document.getElementById('chat-reasoning-wrapper'); if (!wrap) return; const show = modeVal === CHAT_AGENT_MODE_EINO_SINGLE || (multiAgentAPIEnabled && chatAgentModeIsEino(modeVal)); @@ -953,11 +1029,456 @@ function currentChatAIChannelLabel() { const id = selectedChatAIChannelId() || chatDefaultAIChannel; const ch = id ? chatAIChannels[id] : null; if (!ch) { - return typeof window.t === 'function' ? window.t('chat.aiChannelDefaultShort') : '默认通道'; + return chatTranslate('chat.aiChannelDefaultShort', '默认通道'); } return ch.name || id; } +function currentChatModelLabel() { + const id = selectedChatAIChannelId() || chatDefaultAIChannel; + const ch = id ? chatAIChannels[id] : null; + const model = ch && typeof ch.model === 'string' ? ch.model.trim() : ''; + return model || currentChatAIChannelLabel(); +} + +function currentSystemModelLabel() { + const ch = chatDefaultAIChannel ? chatAIChannels[chatDefaultAIChannel] : null; + const model = ch && typeof ch.model === 'string' ? ch.model.trim() : ''; + return model || (ch && (ch.name || chatDefaultAIChannel)) || currentChatModelLabel(); +} + +function currentHitlAuditModelLabel() { + return chatHitlAuditModelName || currentSystemModelLabel(); +} + +function currentSystemReasoningEffort() { + const ch = chatDefaultAIChannel ? chatAIChannels[chatDefaultAIChannel] : null; + const reasoning = ch && ch.reasoning && typeof ch.reasoning === 'object' ? ch.reasoning : {}; + const effort = typeof reasoning.effort === 'string' ? reasoning.effort.trim() : ''; + return ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) ? effort : ''; +} + +function chatSystemModelConfigState(cfg) { + const source = cfg && typeof cfg === 'object' ? cfg : {}; + const sourceAI = source.ai && typeof source.ai === 'object' ? source.ai : {}; + const channels = sourceAI.channels && typeof sourceAI.channels === 'object' + ? { ...sourceAI.channels } + : {}; + let channelId = String(sourceAI.default_channel || '').trim(); + if (!channels[channelId]) { + const normalized = normalizeChatAIChannelId(channelId); + channelId = Object.keys(channels).find(function (id) { + return normalizeChatAIChannelId(id) === normalized; + }) || ''; + } + if (!channelId) channelId = Object.keys(channels)[0] || 'default'; + if (!channels[channelId]) { + const legacy = source.openai && typeof source.openai === 'object' ? source.openai : {}; + channels[channelId] = { + name: channelId === 'default' ? 'Default' : channelId, + provider: legacy.provider || 'openai', + api_key: legacy.api_key || '', + base_url: legacy.base_url || '', + model: legacy.model || '' + }; + } + return { + ai: { ...sourceAI, default_channel: channelId, channels: channels }, + channelId: channelId, + channel: channels[channelId] + }; +} + +function chatSystemModelElements() { + return { + wrap: document.getElementById('chat-model-shortcut-wrap'), + button: document.getElementById('chat-model-shortcut'), + menu: document.getElementById('chat-system-model-menu'), + main: document.getElementById('chat-system-model-main'), + subview: document.getElementById('chat-system-model-subview'), + subviewTitle: document.getElementById('chat-system-model-subview-title'), + list: document.getElementById('chat-system-model-list'), + status: document.getElementById('chat-system-model-status'), + subviewStatus: document.getElementById('chat-system-model-subview-status'), + currentValue: document.getElementById('chat-system-model-current-value'), + effortValue: document.getElementById('chat-system-model-effort-value') + }; +} + +function setChatSystemModelStatus(message, tone) { + const ui = chatSystemModelElements(); + [ui.status, ui.subviewStatus].forEach(function (status) { + if (!status) return; + status.textContent = message || ''; + status.dataset.tone = tone || ''; + }); +} + +function chatReasoningEffortLabel(value) { + switch (String(value || '').trim()) { + case 'low': return 'low'; + case 'medium': return 'medium'; + case 'high': return 'high'; + case 'xhigh': return 'xhigh'; + case 'max': return 'max'; + default: return chatTranslate('chat.reasoningEffortUnset', '不指定'); + } +} + +function currentChatReasoningEffort() { + const effort = document.getElementById('chat-reasoning-effort'); + return effort ? String(effort.value || '').trim() : ''; +} + +function updateChatSystemModelPickerValues() { + const ui = chatSystemModelElements(); + const model = currentSystemModelLabel(); + const effort = chatReasoningEffortLabel(currentSystemReasoningEffort()); + if (ui.currentValue) ui.currentValue.textContent = model; + if (ui.effortValue) ui.effortValue.textContent = effort; + const composerEffort = document.getElementById('chat-model-shortcut-effort'); + if (composerEffort) composerEffort.textContent = effort; +} + +function closeChatSystemModelPicker(force) { + if (chatSystemModelSaving && !force) return; + const ui = chatSystemModelElements(); + if (ui.menu) ui.menu.hidden = true; + if (ui.button) { + ui.button.classList.remove('active'); + ui.button.setAttribute('aria-expanded', 'false'); + } + if (ui.main) ui.main.hidden = false; + if (ui.subview) ui.subview.hidden = true; + chatSystemModelRequestSeq += 1; +} + +async function readChatSystemModelError(response, fallback) { + try { + const body = await response.json(); + return body.error || body.message || fallback; + } catch (_) { + return fallback; + } +} + +function renderChatSystemModelOptions(models, currentModel) { + const ui = chatSystemModelElements(); + if (!ui.list) return 0; + ui.list.innerHTML = ''; + const unique = []; + const seen = new Set(); + [currentModel].concat(Array.isArray(models) ? models : []).forEach(function (value) { + const model = String(value || '').trim(); + if (!model || seen.has(model)) return; + seen.add(model); + unique.push(model); + }); + unique.forEach(function (model) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'chat-system-model-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', model === currentModel ? 'true' : 'false'); + option.dataset.model = model; + + const label = document.createElement('span'); + label.className = 'chat-system-model-option-label'; + label.textContent = model; + option.appendChild(label); + + if (model === currentModel) { + option.classList.add('is-selected'); + const current = document.createElement('span'); + current.className = 'chat-system-model-current'; + current.textContent = chatTranslate('chat.systemModelCurrent', '当前'); + option.appendChild(current); + } + option.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + selectChatSystemModel(model); + }); + ui.list.appendChild(option); + }); + return unique.length; +} + +function renderChatReasoningEffortOptions() { + const ui = chatSystemModelElements(); + if (!ui.list) return; + ui.list.innerHTML = ''; + const currentEffort = currentSystemReasoningEffort(); + ['', 'low', 'medium', 'high', 'xhigh', 'max'].forEach(function (effort) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'chat-system-model-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', effort === currentEffort ? 'true' : 'false'); + if (effort === currentEffort) option.classList.add('is-selected'); + + const label = document.createElement('span'); + label.className = 'chat-system-model-option-label chat-system-effort-option-label'; + label.textContent = chatReasoningEffortLabel(effort); + option.appendChild(label); + + if (effort === currentEffort) { + const current = document.createElement('span'); + current.className = 'chat-system-model-current'; + current.textContent = chatTranslate('chat.systemModelCurrent', '当前'); + option.appendChild(current); + } + option.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + selectChatReasoningEffort(effort); + }); + ui.list.appendChild(option); + }); +} + +async function selectChatReasoningEffort(effort) { + if (chatSystemModelSaving) return; + if (typeof requirePermission === 'function' && !requirePermission('config:write')) return; + const chosen = ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(String(effort || '').trim()) + ? String(effort || '').trim() + : ''; + const ui = chatSystemModelElements(); + chatSystemModelSaving = true; + if (ui.list) { + ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = true; }); + } + setChatSystemModelStatus(chatTranslate('chat.systemModelSaving', '正在保存…'), 'loading'); + try { + const latestResponse = await apiFetch('/api/config'); + if (!latestResponse.ok) { + throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); + } + const latest = await latestResponse.json(); + const state = chatSystemModelConfigState(latest); + state.ai.channels[state.channelId] = { + ...state.channel, + reasoning: { ...(state.channel.reasoning || {}), effort: chosen } + }; + const updateResponse = await apiFetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ai: state.ai }) + }); + if (!updateResponse.ok) { + throw new Error(await readChatSystemModelError(updateResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); + } + const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' }); + if (!applyResponse.ok) { + throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败'))); + } + chatAIChannels = state.ai.channels; + chatDefaultAIChannel = state.channelId; + await initChatAgentModeFromConfig(); + updateChatComposerSessionShortcuts(); + renderChatReasoningEffortOptions(); + setChatSystemModelStatus(chatTranslate('chat.systemModelSaved', '已自动保存'), 'success'); + if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer); + chatSystemModelCloseTimer = window.setTimeout(function () { + chatSystemModelSaving = false; + closeChatSystemModelPicker(true); + }, 650); + return; + } catch (error) { + console.error('selectChatReasoningEffort', error); + setChatSystemModelStatus(error.message || chatTranslate('chat.systemModelSaveFailed', '保存失败'), 'error'); + } + chatSystemModelSaving = false; + if (ui.list) { + ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = false; }); + } +} + +function renderChatSystemModelRetry() { + const ui = chatSystemModelElements(); + if (!ui.list) return; + ui.list.innerHTML = ''; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'chat-system-model-retry'; + retry.textContent = chatTranslate('chat.systemModelRetry', '重新获取'); + retry.addEventListener('click', function (retryEvent) { + closeChatSystemModelPicker(true); + openChatSystemModelPicker(retryEvent); + }); + ui.list.appendChild(retry); +} + +function openChatSystemModelView(view, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const ui = chatSystemModelElements(); + if (!ui.main || !ui.subview || !ui.list) return; + if (view === 'main') { + ui.main.hidden = false; + ui.subview.hidden = true; + updateChatSystemModelPickerValues(); + return; + } + ui.main.hidden = true; + ui.subview.hidden = false; + ui.subview.dataset.view = view; + if (view === 'effort') { + if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.reasoningEffortLabel', '推理强度'); + setChatSystemModelStatus('', ''); + renderChatReasoningEffortOptions(); + return; + } + if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.systemModelField', '模型'); + if (chatSystemModelOptions.length) { + const count = renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent); + setChatSystemModelStatus( + chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)), + 'success' + ); + } else if (chatSystemModelLoadError) { + renderChatSystemModelRetry(); + setChatSystemModelStatus(chatSystemModelLoadError, 'error'); + } else { + ui.list.innerHTML = ''; + setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading'); + } +} + +async function selectChatSystemModel(model) { + if (chatSystemModelSaving) return; + if (typeof requirePermission === 'function' && !requirePermission('config:write')) return; + const chosen = String(model || '').trim(); + if (!chosen) return; + const ui = chatSystemModelElements(); + chatSystemModelSaving = true; + if (ui.list) { + ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = true; }); + } + setChatSystemModelStatus(chatTranslate('chat.systemModelSaving', '正在保存…'), 'loading'); + try { + const latestResponse = await apiFetch('/api/config'); + if (!latestResponse.ok) { + throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); + } + const latest = await latestResponse.json(); + const state = chatSystemModelConfigState(latest); + state.ai.channels[state.channelId] = { ...state.channel, model: chosen }; + const updateResponse = await apiFetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ai: state.ai }) + }); + if (!updateResponse.ok) { + throw new Error(await readChatSystemModelError(updateResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); + } + const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' }); + if (!applyResponse.ok) { + throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败'))); + } + chatAIChannels = state.ai.channels; + chatDefaultAIChannel = state.channelId; + updateChatComposerSessionShortcuts(); + await initChatAgentModeFromConfig(); + chatSystemModelCurrent = chosen; + chatSystemModelOptions = [chosen].concat(chatSystemModelOptions); + updateChatSystemModelPickerValues(); + renderChatSystemModelOptions(chatSystemModelOptions, chosen); + setChatSystemModelStatus(chatTranslate('chat.systemModelSaved', '已自动保存'), 'success'); + if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer); + chatSystemModelCloseTimer = window.setTimeout(function () { + chatSystemModelSaving = false; + closeChatSystemModelPicker(true); + }, 650); + return; + } catch (error) { + console.error('selectChatSystemModel', error); + setChatSystemModelStatus(error.message || chatTranslate('chat.systemModelSaveFailed', '保存失败'), 'error'); + } + chatSystemModelSaving = false; + if (ui.list) { + ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = false; }); + } +} + +async function openChatSystemModelPicker(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const ui = chatSystemModelElements(); + if (!ui.menu || !ui.button || !ui.list) return; + if (!ui.menu.hidden) { + closeChatSystemModelPicker(); + return; + } + if (chatSystemModelCloseTimer) { + window.clearTimeout(chatSystemModelCloseTimer); + chatSystemModelCloseTimer = null; + } + if (typeof closeChatReasoningPanel === 'function') closeChatReasoningPanel(); + ui.menu.hidden = false; + ui.button.classList.add('active'); + ui.button.setAttribute('aria-expanded', 'true'); + if (ui.main) ui.main.hidden = false; + if (ui.subview) ui.subview.hidden = true; + ui.list.innerHTML = ''; + chatSystemModelOptions = []; + chatSystemModelCurrent = currentSystemModelLabel(); + chatSystemModelLoadError = ''; + updateChatSystemModelPickerValues(); + setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading'); + const requestId = ++chatSystemModelRequestSeq; + try { + const configResponse = await apiFetch('/api/config'); + if (!configResponse.ok) { + throw new Error(await readChatSystemModelError(configResponse, chatTranslate('chat.systemModelLoadFailed', '获取模型失败'))); + } + const cfg = await configResponse.json(); + const state = chatSystemModelConfigState(cfg); + const channel = state.channel || {}; + if (!String(channel.api_key || '').trim()) { + throw new Error(chatTranslate('chat.systemModelNeedApiKey', '请先在系统设置中配置 API Key')); + } + const listResponse = await apiFetch('/api/config/list-models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: channel.provider || 'openai', + base_url: String(channel.base_url || '').trim(), + api_key: String(channel.api_key || '').trim() + }) + }); + const result = await listResponse.json().catch(function () { return {}; }); + if (!listResponse.ok || !result.success) { + throw new Error(result.error || chatTranslate('chat.systemModelLoadFailed', '获取模型失败')); + } + if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; + chatSystemModelCurrent = String(channel.model || '').trim(); + chatSystemModelOptions = Array.isArray(result.models) ? result.models.slice() : []; + const count = [chatSystemModelCurrent].concat(chatSystemModelOptions) + .map(function (model) { return String(model || '').trim(); }) + .filter(function (model, index, all) { return model && all.indexOf(model) === index; }) + .length; + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent); + } + setChatSystemModelStatus( + chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)), + 'success' + ); + } catch (error) { + if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; + chatSystemModelLoadError = error.message || chatTranslate('chat.systemModelLoadFailed', '获取模型失败'); + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + renderChatSystemModelRetry(); + } + setChatSystemModelStatus(chatSystemModelLoadError, 'error'); + } +} + function truncateChatAIChannelSummaryLabel(label) { const chars = Array.from(String(label || '')); if (chars.length <= CHAT_AI_CHANNEL_SUMMARY_NAME_MAX) return chars.join(''); @@ -975,12 +1496,11 @@ function persistChatAIChannelPref() { function reasoningSummaryModeLabel(mode) { const m = (mode || 'default').trim(); - const t = (typeof window.t === 'function') ? window.t : function (k) { return k; }; switch (m) { - case 'off': return t('chat.reasoningModeOff'); - case 'on': return t('chat.reasoningModeOn'); - case 'auto': return t('chat.reasoningModeAuto'); - default: return t('chat.reasoningSummaryFollow'); + case 'off': return chatTranslate('chat.reasoningModeOff', '关闭'); + case 'on': return chatTranslate('chat.reasoningModeOn', '开启'); + case 'auto': return chatTranslate('chat.reasoningModeAuto', '自动'); + default: return chatTranslate('chat.reasoningSummaryFollow', '系统'); } } @@ -1002,9 +1522,210 @@ function updateChatReasoningSummary() { hitlPart = ''; } const channelPart = currentChatAIChannelLabel(); + const modelPart = currentChatModelLabel(); const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean); el.textContent = parts.join(' / '); el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / '); + updateChatComposerSessionShortcuts({ + channel: channelPart, + model: modelPart, + reasoning: reasoningPart, + hitl: hitlPart + }); +} + +function updateChatComposerSessionShortcuts(summary) { + const data = summary || {}; + const modelEl = document.getElementById('chat-model-shortcut-text'); + const hitlEl = document.getElementById('chat-hitl-shortcut-text'); + if (modelEl) { + // 输入框右侧只展示系统默认主模型;审批模型只出现在 HITL 入口。 + const label = currentSystemModelLabel(); + modelEl.textContent = truncateChatAIChannelSummaryLabel(label); + modelEl.title = label; + const shortcut = document.getElementById('chat-model-shortcut'); + if (shortcut) { + const effort = chatReasoningEffortLabel(currentSystemReasoningEffort()); + const action = chatTranslate('chat.modelSettingsAria', '选择模型与推理强度'); + shortcut.setAttribute('aria-label', action + ':' + label + ' · ' + effort); + shortcut.title = action + ':' + label + ' · ' + effort; + } + updateChatSystemModelPickerValues(); + } + if (hitlEl) { + const cfg = readHitlConfigFromForm(); + const auditAgent = normalizeHitlReviewer(cfg.reviewer) === 'audit_agent'; + const prefix = auditAgent + ? chatTranslate('chat.sessionShortcutAuditAgent', 'Agent 审查') + : chatTranslate('chat.sessionShortcutHuman', '人工审批'); + const modeLabel = data.hitl || getHitlModeLabel(cfg.mode); + const approvalModel = auditAgent ? currentHitlAuditModelLabel() : ''; + const label = prefix + ':' + modeLabel + (approvalModel ? ' · ' + approvalModel : ''); + hitlEl.textContent = label; + hitlEl.title = label; + } +} + +function openChatSessionSettings(section, event) { + if (event && typeof event.stopPropagation === 'function') event.stopPropagation(); + mountChatSessionSettingsPopover(); + const wrap = document.getElementById('chat-reasoning-wrapper'); + const toggle = document.getElementById('conversation-reasoning-toggle'); + if (!wrap || !toggle || wrap.style.display === 'none') return; + syncChatReasoningBarHeight(); + wrap.classList.remove('conversation-reasoning-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + if (typeof closeAgentModePanel === 'function') closeAgentModePanel(); + if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel(); + if (typeof closeChatProjectPanel === 'function') closeChatProjectPanel(); + updateChatReasoningSummary(); + + let target = null; + if (section === 'hitl') target = document.getElementById('hitl-mode-select'); + else if (section === 'reasoning') target = document.getElementById('chat-reasoning-mode'); + else target = document.getElementById('chat-ai-channel-select'); + const group = target && target.closest('.session-settings-group'); + if (group && typeof group.scrollIntoView === 'function') { + group.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + const customTrigger = target && target.closest('.session-settings-select') + ? target.closest('.session-settings-select').querySelector('.session-settings-select-trigger') + : null; + window.setTimeout(function () { + if (customTrigger) customTrigger.focus({ preventScroll: true }); + else if (target) target.focus({ preventScroll: true }); + }, 180); +} + +function getVisibleChatConversationId() { + return typeof currentConversationId === 'string' && currentConversationId.trim() + ? currentConversationId.trim() + : ''; +} + +function shouldTreatLiveChatTaskAsCurrent(liveConversationId, visibleConversationId, hasVisibleProgress) { + const liveId = String(liveConversationId || '').trim(); + const visibleId = String(visibleConversationId || '').trim(); + if (liveId) return !!visibleId && liveId === visibleId; + return hasVisibleProgress === true; +} + +function ownsLiveChatStream(liveStream) { + return !!liveStream && window.__csAgentLiveStream === liveStream; +} + +function clearLiveChatStreamIfOwned(liveStream) { + if (!ownsLiveChatStream(liveStream)) return false; + liveStream.active = false; + window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null }; + updateChatPrimaryActionState(); + return true; +} + +/** + * 离开正在读取主 POST 流的对话时,只断开浏览器侧响应流,不停止后端任务。 + * 后端任务使用 detachedAgentContext,仍会继续运行;重新进入该对话时由 + * task-events 镜像流接管。这样同时运行多个对话也只占用一个前台长连接, + * 不会耗尽浏览器对同一主机的连接槽位而卡住普通 GET/POST 请求。 + */ +function detachLiveChatStreamForNavigation(nextConversationId, force = false) { + const liveStream = window.__csAgentLiveStream; + if (!liveStream || !liveStream.active) return false; + const liveConversationId = String(liveStream.conversationId || '').trim(); + const nextId = String(nextConversationId || '').trim(); + if (!force && liveConversationId && liveConversationId === nextId) return false; + if (!force && !liveConversationId && !nextId) return false; + + liveStream.detached = true; + liveStream.active = false; + const controller = liveStream.abortController; + if (controller && !controller.signal.aborted) { + controller.abort(); + } + if (ownsLiveChatStream(liveStream)) { + updateChatPrimaryActionState(); + } + return true; +} + +function cancelPendingConversationLoad() { + if (!loadConversationAbortController) return false; + if (!loadConversationAbortController.signal.aborted) { + loadConversationAbortController.abort(); + } + loadConversationAbortController = null; + return true; +} + +function isLiveChatTaskVisible(live, visibleConversationId) { + if (!live || !live.active) return false; + const progress = live.progressId ? document.getElementById(live.progressId) : null; + const hasVisibleProgress = !!(progress && progress.closest('#chat-messages')); + return shouldTreatLiveChatTaskAsCurrent( + live.conversationId, + visibleConversationId, + hasVisibleProgress + ); +} + +function getCurrentChatTaskConversationId() { + const visibleConversationId = getVisibleChatConversationId(); + if (visibleConversationId) return visibleConversationId; + return ''; +} + +function isCurrentChatTaskActive() { + const live = window.__csAgentLiveStream; + const visibleConversationId = getVisibleChatConversationId(); + if (isLiveChatTaskVisible(live, visibleConversationId)) return true; + return !!visibleConversationId && + typeof isConversationTaskRunning === 'function' && + isConversationTaskRunning(visibleConversationId); +} + +function updateChatPrimaryActionState() { + const button = document.getElementById('chat-send-btn'); + if (!button) return; + const running = isCurrentChatTaskActive(); + const label = running + ? chatTranslate('tasks.stopTask', '停止任务') + : chatTranslate('chat.send', '发送'); + button.classList.toggle('is-task-running', running); + button.setAttribute('aria-label', label); + button.setAttribute('title', label); + const labelElement = button.querySelector('.send-btn-label'); + if (labelElement) labelElement.textContent = label; +} + +function handleChatPrimaryAction(event) { + if (event) event.preventDefault(); + if (!isCurrentChatTaskActive()) { + sendMessage(); + return; + } + + const live = window.__csAgentLiveStream; + const conversationId = getCurrentChatTaskConversationId(); + if (conversationId && typeof cancelActiveTask === 'function') { + cancelActiveTask(conversationId); + return; + } + if (live && live.progressId && typeof cancelProgressTask === 'function') { + cancelProgressTask(live.progressId); + } +} + +function initChatPrimaryActionButton() { + const button = document.getElementById('chat-send-btn'); + if (!button) return; + if (!button.querySelector('.send-btn-stop-icon')) { + const stopIcon = document.createElement('span'); + stopIcon.className = 'send-btn-stop-icon'; + stopIcon.setAttribute('aria-hidden', 'true'); + button.appendChild(stopIcon); + } + button.onclick = handleChatPrimaryAction; + updateChatPrimaryActionState(); } function closeChatReasoningPanel() { @@ -1098,7 +1819,13 @@ if (typeof window !== 'undefined') { window.toggleChatReasoningPanel = toggleChatReasoningPanel; window.toggleConversationReasoningCard = toggleConversationReasoningCard; window.updateChatReasoningSummary = updateChatReasoningSummary; + window.updateChatComposerSessionShortcuts = updateChatComposerSessionShortcuts; + window.openChatSessionSettings = openChatSessionSettings; + window.openChatSystemModelPicker = openChatSystemModelPicker; + window.openChatSystemModelView = openChatSystemModelView; + window.closeChatSystemModelPicker = closeChatSystemModelPicker; window.refreshSessionSettingsSelects = refreshSessionSettingsSelects; + window.updateChatPrimaryActionState = updateChatPrimaryActionState; } function closeAgentModePanel() { @@ -1175,6 +1902,11 @@ async function initChatAgentModeFromConfig() { const cfg = await r.json(); multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled); populateChatAIChannelSelect(cfg.ai || {}); + const hitlAuditModel = cfg.hitl && cfg.hitl.audit_model; + chatHitlAuditModelName = hitlAuditModel && typeof hitlAuditModel.model === 'string' + ? hitlAuditModel.model.trim() + : ''; + updateChatReasoningSummary(); if (typeof window !== 'undefined') { window.__csaiMultiAgentPublic = cfg.multi_agent || null; const tw = cfg.hitl && cfg.hitl.tool_whitelist; @@ -1325,6 +2057,17 @@ async function sendMessage() { return; } + // Enter 会直接调用 sendMessage;同一会话在其他标签页已启动任务时, + // 必须在渲染用户气泡和发起 POST 前做一次权威状态同步,避免生成一轮“已有任务执行中”伪对话。 + if (currentConversationId && typeof loadActiveTasks === 'function') { + await loadActiveTasks(); + } + if (isCurrentChatTaskActive()) { + updateChatPrimaryActionState(); + showChatToast(chatTranslate('chat.taskAlreadyRunning', '当前会话已有任务正在执行,请先等待完成或停止任务。'), 'info'); + return; + } + if (hasAttachments) { const needWait = chatAttachments.some((a) => a.uploading); if (needWait) { @@ -1416,7 +2159,8 @@ async function sendMessage() { enabled: true, mode: normalizeHitlMode(hitlCfg.mode), reviewer: normalizeHitlReviewer(hitlCfg.reviewer), - sensitiveTools: sensitiveTools + sensitiveTools: sensitiveTools, + timeoutSeconds: normalizeHitlTimeoutForChat(hitlCfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS) }; } if (hasAttachments) { @@ -1442,6 +2186,19 @@ async function sendMessage() { } const progressElement = document.getElementById(progressId); registerProgressTask(progressId, streamConversationId); + const requestAbortController = new AbortController(); + const liveStreamState = { + active: true, + conversationId: streamConversationId || null, + progressId: progressId, + abortController: requestAbortController, + detached: false + }; + window.__csAgentLiveStream = liveStreamState; + if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') { + window.notifyConversationTaskStarted(streamConversationId); + } + updateChatPrimaryActionState(); loadActiveTasks(); let assistantMessageId = null; let mcpExecutionIds = []; @@ -1461,17 +2218,14 @@ async function sendMessage() { 'Content-Type': 'application/json', }, body: JSON.stringify(body), + signal: requestAbortController.signal, }); if (!response.ok) { throw new Error('请求失败: ' + response.status); } - window.__csAgentLiveStream = { - active: true, - conversationId: streamConversationId || null, - progressId: progressId - }; + liveStreamState.conversationId = streamConversationId || null; try { const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -1491,7 +2245,14 @@ async function sendMessage() { } if (!streamConversationId && eventData.type === 'conversation') { streamConversationId = eventConvId; + liveStreamState.conversationId = eventConvId; justBoundConversation = true; + // 旧请求可能在用户切换对话后才收到 conversation 事件。 + // 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。 + if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) { + updateProgressConversation(progressId, eventConvId); + return; + } } } if (!justBoundConversation && !isStreamStillVisibleForRequest()) { @@ -1540,8 +2301,14 @@ async function sendMessage() { } const convId = streamConversationId || (body && body.conversationId) || null; let attached = false; - if (convId && typeof window.attachRunningTaskEventStream === 'function') { - window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null }; + if ( + convId && + ownsLiveChatStream(liveStreamState) && + !liveStreamState.detached && + isStreamStillVisibleForRequest() && + typeof window.attachRunningTaskEventStream === 'function' + ) { + clearLiveChatStreamIfOwned(liveStreamState); attached = await window.attachRunningTaskEventStream(convId).catch(() => false); } if (!attached && isStreamStillVisibleForRequest()) { @@ -1552,8 +2319,8 @@ async function sendMessage() { } } } finally { - window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null }; - if (window.CyberStrikeChatScroll) { + const clearedOwnedStream = clearLiveChatStreamIfOwned(liveStreamState); + if (clearedOwnedStream && !liveStreamState.detached && window.CyberStrikeChatScroll) { window.CyberStrikeChatScroll.onStreamEnd(); } } @@ -1567,7 +2334,8 @@ async function sendMessage() { } } catch (error) { - if (!isStreamStillVisibleForRequest()) { + clearLiveChatStreamIfOwned(liveStreamState); + if (liveStreamState.detached || !isStreamStillVisibleForRequest()) { if (typeof loadActiveTasks === 'function') { loadActiveTasks(); } @@ -1993,9 +2761,10 @@ function handleChatInputKeydown(event) { } } + // Enter 直接发送;Shift+Enter 保留 textarea 原生换行行为。 if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - sendMessage(); + void sendMessage(); } } @@ -2367,8 +3136,7 @@ function initializeChatUI() { const messagesDiv = document.getElementById('chat-messages'); if (messagesDiv && messagesDiv.childElementCount === 0) { - const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; - addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true }); + renderChatWelcomeEmptyState(); } addAttackChainButton(currentConversationId); @@ -2404,12 +3172,96 @@ function wrapTablesInBubble(bubble) { }); } -/** - * 将「系统已就绪」类文案按当前语言重新渲染进气泡(与 addMessage 助手分支一致的安全处理) - */ +const PROJECT_NAME_DISPLAY_MAX_CHARACTERS = 12; + +/** 仅限制项目名的界面展示,不修改实际保存的名称。 */ +function formatProjectNameForDisplay(value) { + const fullName = String(value == null ? '' : value); + const characters = Array.from(fullName); + if (characters.length <= PROJECT_NAME_DISPLAY_MAX_CHARACTERS) return fullName; + return `${characters.slice(0, PROJECT_NAME_DISPLAY_MAX_CHARACTERS).join('')}…`; +} + +function applyProjectNameDisplay(element, value, fallback = '') { + if (!element) return ''; + const fullName = String(value || fallback || ''); + element.textContent = formatProjectNameForDisplay(fullName); + element.dataset.fullName = fullName; + element.title = fullName; + return fullName; +} + +window.formatProjectNameForDisplay = formatProjectNameForDisplay; +window.applyProjectNameDisplay = applyProjectNameDisplay; + +function getChatWelcomeProjectName() { + const projectElement = document.getElementById('chat-project-text'); + const projectText = (projectElement?.dataset?.fullName || projectElement?.textContent || '').trim(); + return projectText || (typeof window.t === 'function' ? window.t('projects.noProject') : '无项目'); +} + +function getChatWelcomeText() { + const project = getChatWelcomeProjectName(); + const noProject = typeof window.t === 'function' ? window.t('projects.noProject') : '无项目'; + if (!project || project === noProject) { + return typeof window.t === 'function' + ? window.t('chat.noProjectWelcomeMessage') + : '当前无项目,请输入您的测试需求,系统将自动执行相应的安全测试。'; + } + return typeof window.t === 'function' + ? window.t('chat.projectWelcomeMessage', { project }) + : `当前${project}项目,请输入您的测试需求,系统将自动执行相应的安全测试。`; +} + +function updateChatWelcomeTitle(title) { + if (!title) return; + const project = getChatWelcomeProjectName(); + const noProject = typeof window.t === 'function' ? window.t('projects.noProject') : '无项目'; + const subtitle = title.parentElement?.querySelector('.chat-welcome-empty-state-subtitle'); + + if (project === noProject) { + title.textContent = typeof window.t === 'function' + ? window.t('chat.noProjectWelcomeTitle') + : '要测试什么?'; + } else { + const prefix = typeof window.t === 'function' + ? window.t('chat.projectWelcomeTitlePrefix') + : '要在 '; + const suffix = typeof window.t === 'function' + ? window.t('chat.projectWelcomeTitleSuffix') + : ' 项目中测试什么?'; + const projectName = document.createElement('span'); + projectName.className = 'chat-welcome-project-name'; + applyProjectNameDisplay(projectName, project); + title.replaceChildren(document.createTextNode(prefix), projectName, document.createTextNode(suffix)); + } + + if (subtitle) { + subtitle.textContent = typeof window.t === 'function' + ? window.t('chat.welcomeSubtitle') + : '请输入您的测试需求,系统将自动执行相应的安全测试。'; + } +} + +function renderChatWelcomeEmptyState() { + const messagesDiv = document.getElementById('chat-messages'); + if (!messagesDiv) return null; + messagesDiv.querySelectorAll('.chat-welcome-empty-state').forEach((node) => node.remove()); + const state = document.createElement('div'); + state.className = 'chat-welcome-empty-state'; + state.setAttribute('role', 'status'); + state.setAttribute('aria-live', 'polite'); + state.innerHTML = '

'; + updateChatWelcomeTitle(state.querySelector('.chat-welcome-empty-state-title')); + messagesDiv.appendChild(state); + return state; +} + +/** 更新新对话欢迎空状态,并兼容刷新旧版本遗留的系统就绪消息。 */ function refreshSystemReadyMessageBubbles() { - if (typeof window.t !== 'function') return; - const text = window.t('chat.systemReadyMessage'); + const text = getChatWelcomeText(); + const welcome = document.querySelector('.chat-welcome-empty-state-title'); + if (welcome) updateChatWelcomeTitle(welcome); const escapeHtmlLocal = (s) => { if (!s) return ''; const div = document.createElement('div'); @@ -2431,31 +3283,9 @@ function refreshSystemReadyMessageBubbles() { bubble.innerHTML = formattedContent; if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble); messageDiv.dataset.originalContent = text; - const copyBtnNew = document.createElement('button'); - copyBtnNew.className = 'message-copy-btn'; - copyBtnNew.innerHTML = '' + window.t('common.copy') + ''; - copyBtnNew.title = window.t('chat.copyMessageTitle'); - copyBtnNew.onclick = function (e) { - e.stopPropagation(); - copyMessageToClipboard(messageDiv, this); - }; - bubble.appendChild(copyBtnNew); }); } -function createMessageAvatar(role) { - const avatar = document.createElement('div'); - avatar.className = 'message-avatar'; - if (role === 'user') { - avatar.innerHTML = ''; - } else if (role === 'assistant') { - avatar.innerHTML = ''; - } else { - avatar.textContent = 'S'; - } - return avatar; -} - // 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案) function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) { const messagesDiv = document.getElementById('chat-messages'); @@ -2465,9 +3295,8 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr messageDiv.id = id; messageDiv.className = 'message ' + role; - // 创建头像 - messageDiv.appendChild(createMessageAvatar(role)); - + messagesDiv.querySelector('.chat-welcome-empty-state')?.remove(); + // 创建消息内容容器 const contentWrapper = document.createElement('div'); contentWrapper.className = 'message-content'; @@ -2510,6 +3339,13 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr } bubble.innerHTML = formattedContent; + + // 刷新恢复运行中会话时,后端正文可能仍是持久化占位值“处理中...”。 + // 保留消息节点供迭代详情和最终回复复用,但不要把占位值显示成助手正文。 + if (role === 'assistant' && options && options.hideAssistantPlaceholder) { + messageDiv.classList.add('assistant-placeholder-content'); + bubble.hidden = true; + } if (typeof window.csMarkdownSanitize !== 'undefined') { window.csMarkdownSanitize.stripSuspiciousImages(bubble); @@ -2873,6 +3709,9 @@ function syncProcessDetailButtonLabels(messageId, expanded) { document.querySelectorAll('#' + messageId + ' .process-detail-btn').forEach((btn) => { btn.innerHTML = '' + label + ''; }); + if (typeof window.syncAssistantTurnSummary === 'function') { + window.syncAssistantTurnSummary(document.getElementById(messageId)); + } } /** 懒加载占位提示可点击,与工具栏「展开详情」行为一致 */ @@ -3240,6 +4079,14 @@ function renderProcessDetails(messageId, processDetails, options) { } else if (eventType === 'hitl_interrupt') { const hitlMsg = (detail.message && String(detail.message).trim()) ? String(detail.message).trim() : (typeof window.t === 'function' ? window.t('hitl.pendingTitle') : '待审批'); itemTitle = agPx + '🧑‍⚖️ HITL · ' + hitlMsg; + } else if (eventType === 'hitl_audit_agent_started') { + itemTitle = agPx + '审计 Agent 正在审查'; + } else if (eventType === 'hitl_audit_agent') { + itemTitle = agPx + '审计 Agent 已完成审查'; + } else if (eventType === 'hitl_resumed') { + itemTitle = agPx + '审批已通过'; + } else if (eventType === 'hitl_rejected') { + itemTitle = agPx + '审批已拒绝'; } else if (eventType === 'progress') { itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); } else if (eventType === 'user_interrupt_continue') { @@ -3248,6 +4095,28 @@ function renderProcessDetails(messageId, processDetails, options) { : '⏸️ 用户中断并继续'; } + if (eventType === 'hitl_interrupt' || eventType === 'hitl_audit_agent_started' || + eventType === 'hitl_audit_agent' || eventType === 'hitl_resumed' || eventType === 'hitl_rejected') { + const hitlTarget = typeof findToolCallItemForHitl === 'function' + ? findToolCallItemForHitl(timeline, data) + : null; + if (hitlTarget && hitlTarget.id) { + if (eventType === 'hitl_interrupt' || eventType === 'hitl_audit_agent_started') { + renderInlineHitlApproval(hitlTarget.id, Object.assign({}, data, { + reviewer: eventType === 'hitl_audit_agent_started' ? 'audit_agent' : (data.reviewer || 'human'), + status: eventType === 'hitl_audit_agent_started' ? 'audit_running' : (data.status || 'pending') + })); + } else { + const decision = eventType === 'hitl_rejected' || data.decision === 'reject' ? 'reject' : 'approve'; + resolveInlineHitlDecision(timeline, Object.assign({}, data, { + reviewer: data.reviewer || data.decidedBy || (eventType === 'hitl_audit_agent' ? 'audit_agent' : 'human'), + status: 'decided' + }), decision, detail.message || ''); + } + return; + } + } + const timelineOpts = { title: itemTitle, message: detail.message || '', @@ -3268,6 +4137,19 @@ function renderProcessDetails(messageId, processDetails, options) { timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id)); } const itemId = addTimelineItem(timeline, eventType, timelineOpts); + if (itemId && (eventType === 'hitl_interrupt' || eventType === 'hitl_audit_agent_started')) { + renderInlineHitlApproval(itemId, Object.assign({}, data, { + reviewer: eventType === 'hitl_audit_agent_started' ? 'audit_agent' : (data.reviewer || 'human'), + status: eventType === 'hitl_audit_agent_started' ? 'audit_running' : (data.status || 'pending') + })); + } else if (itemId && (eventType === 'hitl_audit_agent' || eventType === 'hitl_resumed' || eventType === 'hitl_rejected')) { + renderInlineHitlApproval(itemId, Object.assign({}, data, { + resolved: true, + decision: eventType === 'hitl_rejected' || data.decision === 'reject' ? 'reject' : 'approve', + reviewer: data.reviewer || data.decidedBy || (eventType === 'hitl_audit_agent' ? 'audit_agent' : 'human'), + status: 'decided' + })); + } if (prependMode && itemId) { prependedIds.push(itemId); } @@ -3357,6 +4239,14 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) { const j = await res.json().catch(() => ({})); if (!res.ok || !j.summary) return; const s = j.summary; + if (typeof window.setAssistantTurnTiming === 'function') { + window.setAssistantTurnTiming(messageElement, { + startedAt: s.startedAt, + completedAt: s.completedAt, + durationMs: s.durationMs, + status: s.status || 'completed' + }); + } const summaryMcpIds = Array.isArray(s.mcpExecutionIds) ? s.mcpExecutionIds : []; const summaryTools = Array.isArray(s.toolExecutions) ? s.toolExecutions : []; if (summaryTools.length > 0) { @@ -3399,7 +4289,7 @@ function removeMessage(id) { } } -// 输入框事件绑定(回车发送 / @提及) +// 输入框事件绑定(Enter 发送、Shift+Enter 换行 / @提及) const chatInput = document.getElementById('chat-input'); if (chatInput) { chatInput.addEventListener('keydown', handleChatInputKeydown); @@ -3676,6 +4566,154 @@ function formatMcpToolsToggleLabel(count, expanded) { return count + '次工具执行'; } +function formatAssistantTurnDuration(durationMs) { + const totalSeconds = Math.max(0, Math.floor((Number(durationMs) || 0) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return typeof window.t === 'function' + ? window.t('chat.turnDurationHours', { hours: hours, minutes: minutes }) + : hours + ' 小时 ' + minutes + ' 分钟'; + } + if (minutes > 0) { + return typeof window.t === 'function' + ? window.t('chat.turnDurationMinutes', { minutes: minutes, seconds: seconds }) + : minutes + ' 分钟 ' + seconds + ' 秒'; + } + return typeof window.t === 'function' + ? window.t('chat.turnDurationSeconds', { seconds: seconds }) + : seconds + ' 秒'; +} + +function assistantTurnTimestamp(value) { + if (value == null || value === '') return NaN; + const n = new Date(value).getTime(); + return Number.isFinite(n) ? n : NaN; +} + +function assistantTurnTerminalState(processDetails) { + if (!Array.isArray(processDetails)) return null; + for (let i = processDetails.length - 1; i >= 0; i--) { + const detail = processDetails[i] || {}; + const eventType = String(detail.eventType || '').trim().toLowerCase(); + if (eventType === 'cancelled') { + return { status: 'cancelled', completedAt: detail.createdAt || null, detail: detail }; + } + if (eventType === 'timeout') { + return { status: 'timeout', completedAt: detail.createdAt || null, detail: detail }; + } + if (eventType === 'error') { + return { status: 'failed', completedAt: detail.createdAt || null, detail: detail }; + } + } + return null; +} + +let assistantTurnElapsedTimer = null; + +function syncRunningAssistantTurnSummaries() { + const runningTurns = document.querySelectorAll('#chat-messages .message.assistant[data-turn-status="running"]'); + runningTurns.forEach((messageElement) => syncAssistantTurnSummary(messageElement)); + if (runningTurns.length === 0 && assistantTurnElapsedTimer) { + clearInterval(assistantTurnElapsedTimer); + assistantTurnElapsedTimer = null; + } +} + +function syncAssistantTurnElapsedClock() { + const hasRunningTurn = !!document.querySelector('#chat-messages .message.assistant[data-turn-status="running"]'); + if (hasRunningTurn && !assistantTurnElapsedTimer) { + assistantTurnElapsedTimer = setInterval(syncRunningAssistantTurnSummaries, 1000); + } else if (!hasRunningTurn && assistantTurnElapsedTimer) { + clearInterval(assistantTurnElapsedTimer); + assistantTurnElapsedTimer = null; + } +} + +function setAssistantTurnTiming(messageElementOrId, timing) { + const messageElement = typeof messageElementOrId === 'string' + ? document.getElementById(messageElementOrId) + : messageElementOrId; + if (!messageElement || !messageElement.dataset) return; + const value = timing || {}; + if (value.startedAt) messageElement.dataset.turnStartedAt = String(value.startedAt); + if (value.completedAt) messageElement.dataset.turnCompletedAt = String(value.completedAt); + if (value.status) messageElement.dataset.turnStatus = String(value.status); + const status = String(messageElement.dataset.turnStatus || 'completed'); + if (status === 'running') { + // 摘要接口对运行中任务返回 durationMs=0。刷新页面时不能把这个快照 + // 当作固定耗时保存,否则后续渲染会一直显示“已处理 0 秒”。 + delete messageElement.dataset.turnDurationMs; + delete messageElement.dataset.turnCompletedAt; + } else { + const explicitDuration = Number(value.durationMs); + if (Number.isFinite(explicitDuration) && explicitDuration >= 0) { + messageElement.dataset.turnDurationMs = String(Math.round(explicitDuration)); + } + const startedAt = assistantTurnTimestamp(messageElement.dataset.turnStartedAt); + const completedAt = assistantTurnTimestamp(messageElement.dataset.turnCompletedAt); + if ((!Number.isFinite(explicitDuration) || explicitDuration < 0) && + Number.isFinite(startedAt) && Number.isFinite(completedAt) && completedAt >= startedAt) { + messageElement.dataset.turnDurationMs = String(completedAt - startedAt); + } + } + syncAssistantTurnSummary(messageElement); + syncAssistantTurnElapsedClock(); +} + +function syncAssistantTurnSummary(messageElementOrId) { + const messageElement = typeof messageElementOrId === 'string' + ? document.getElementById(messageElementOrId) + : messageElementOrId; + if (!messageElement) return; + const label = messageElement.querySelector('.mcp-call-label.turn-process-summary'); + if (!label) return; + const details = messageElement.querySelector('.process-details-container'); + const timeline = details && details.querySelector('.progress-timeline'); + const expanded = !!(timeline && timeline.classList.contains('expanded')); + const status = String(messageElement.dataset.turnStatus || 'completed'); + let durationMs = Number(messageElement.dataset.turnDurationMs); + if (!Number.isFinite(durationMs) || durationMs < 0) { + const startedAt = assistantTurnTimestamp(messageElement.dataset.turnStartedAt); + const completedAt = status === 'running' + ? Date.now() + : assistantTurnTimestamp(messageElement.dataset.turnCompletedAt); + durationMs = Number.isFinite(startedAt) && Number.isFinite(completedAt) + ? Math.max(0, completedAt - startedAt) + : 0; + } + const duration = formatAssistantTurnDuration(durationMs); + let text; + if (status === 'running') { + text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration; + } else if (status === 'cancelled') { + text = typeof window.t === 'function' ? window.t('chat.turnElapsedCancelled', { duration: duration }) : '已中断 · 耗时 ' + duration; + } else if (status === 'timeout') { + text = typeof window.t === 'function' ? window.t('chat.turnElapsedTimeout', { duration: duration }) : '已超时 · 耗时 ' + duration; + } else if (status === 'failed') { + text = typeof window.t === 'function' ? window.t('chat.turnElapsedFailed', { duration: duration }) : '执行失败 · 耗时 ' + duration; + } else { + text = typeof window.t === 'function' ? window.t('chat.turnElapsedComplete', { duration: duration }) : '耗时 ' + duration; + } + label.innerHTML = ` + + + ${escapeHtml(text)} + + + `; + label.classList.toggle('is-expanded', expanded); + label.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + label.setAttribute('aria-label', typeof window.t === 'function' + ? window.t('chat.turnProcessAria', { state: text }) + : text + ',展开或收起执行过程'); +} + +window.setAssistantTurnTiming = setAssistantTurnTiming; +window.syncAssistantTurnSummary = syncAssistantTurnSummary; +window.formatAssistantTurnDuration = formatAssistantTurnDuration; + /** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */ function ensureMcpCallSectionChrome(messageElement, messageId) { const contentWrapper = messageElement && messageElement.querySelector('.message-content'); @@ -3685,19 +4723,27 @@ function ensureMcpCallSectionChrome(messageElement, messageId) { if (!mcpSection) { mcpSection = document.createElement('div'); mcpSection.className = 'mcp-call-section'; - const mcpLabel = document.createElement('div'); - mcpLabel.className = 'mcp-call-label'; - mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '任务执行详情'); + const mcpLabel = document.createElement('button'); + mcpLabel.type = 'button'; + mcpLabel.className = 'mcp-call-label turn-process-summary'; + mcpLabel.onclick = function (event) { + event.stopPropagation(); + toggleProcessDetails(null, messageId || messageElement.id); + }; mcpSection.appendChild(mcpLabel); - contentWrapper.appendChild(mcpSection); - } else { - const mcpLabel = mcpSection.querySelector('.mcp-call-label'); - const labelText = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '任务执行详情'); - if (mcpLabel && mcpLabel.textContent !== labelText) { - mcpLabel.textContent = labelText; + const resultBubble = contentWrapper.querySelector(':scope > .message-bubble'); + contentWrapper.insertBefore(mcpSection, resultBubble || contentWrapper.firstChild); + } else if (mcpSection.parentNode === contentWrapper) { + const resultBubble = contentWrapper.querySelector(':scope > .message-bubble'); + if (resultBubble && mcpSection.nextSibling !== resultBubble) { + contentWrapper.insertBefore(mcpSection, resultBubble); } } + messageElement.classList.add('assistant-turn-with-process'); + const resultBubble = contentWrapper.querySelector(':scope > .message-bubble'); + if (resultBubble) resultBubble.classList.add('assistant-final-result'); + let toolbar = mcpSection.querySelector('.mcp-call-toolbar'); if (!toolbar) { toolbar = document.createElement('div'); @@ -3726,6 +4772,7 @@ function ensureMcpCallSectionChrome(messageElement, messageId) { toolbar.appendChild(processDetailBtn); } + syncAssistantTurnSummary(messageElement); return { mcpSection, toolbar, toolList }; } @@ -4354,7 +5401,23 @@ function copyDetailBlock(elementId, triggerBtn = null) { // 开始新对话 -async function startNewConversation() { +async function startNewConversation(options = {}) { + const hasExplicitProjectId = !!options + && Object.prototype.hasOwnProperty.call(options, 'projectId'); + const inheritedProjectId = typeof resolveChatProjectSelection === 'function' + ? resolveChatProjectSelection() + : (window._loadedConversationProjectId || ''); + const requestedProjectId = hasExplicitProjectId + ? String(options.projectId || '').trim() + : String(inheritedProjectId || '').trim(); + cancelPendingConversationLoad(); + detachLiveChatStreamForNavigation('', true); + if (typeof window.cancelRunningTaskEventStream === 'function') { + window.cancelRunningTaskEventStream(''); + } + if (typeof window.clearChatHitlApprovalDock === 'function') { + window.clearChatHitlApprovalDock(); + } // 如果当前在分组详情页面,先退出分组详情 if (currentGroupId) { const groupDetailPage = document.getElementById('group-detail-page'); @@ -4371,18 +5434,16 @@ async function startNewConversation() { try { window.currentConversationId = ''; } catch (e) { /* ignore */ } + updateChatPrimaryActionState(); currentConversationGroupId = null; // 新对话不属于任何分组 - if (typeof ensureDefaultActiveProjectForNewChat === 'function') { - try { - await ensureDefaultActiveProjectForNewChat(); - } catch (e) { /* ignore */ } - } + // 顶部“新任务”继承当前文件夹;文件夹内的“+”仍可显式指定(包括无项目)。 + if (typeof setActiveProjectId === 'function') setActiveProjectId(requestedProjectId); if (typeof refreshChatProjectSelector === 'function') { await refreshChatProjectSelector(); } document.getElementById('chat-messages').innerHTML = ''; - const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; - addMessage('assistant', readyMsgNew, null, null, null, { systemReadyMessage: true }); + updateChatPrimaryActionState(); + renderChatWelcomeEmptyState(); addAttackChainButton(null); updateActiveConversation(); // 刷新分组列表,清除分组高亮 @@ -4520,6 +5581,193 @@ function clearConversationSearch() { loadConversations(''); } +function conversationSidebarText(key, fallback) { + if (typeof window.t === 'function') { + const translated = window.t(key); + if (translated && translated !== key) return translated; + } + return fallback; +} + +/** + * Go 进程会嵌入 index.html。开发时即使进程尚未重启,也通过新版静态 JS + * 将旧侧栏升级为项目文件夹结构;新模板已包含结构时该函数保持幂等。 + */ +function ensureProjectSidebarStructure() { + const sidebar = document.getElementById('conversation-sidebar'); + const sidebarContent = sidebar && sidebar.querySelector('.sidebar-content'); + if (!sidebar || !sidebarContent) return; + + const newTaskLabel = sidebar.querySelector('.new-chat-btn span:last-child'); + if (newTaskLabel) { + newTaskLabel.setAttribute('data-i18n', 'chat.newTask'); + newTaskLabel.textContent = conversationSidebarText('chat.newTask', '新任务'); + } + + const searchInput = document.getElementById('conversation-search-input'); + if (searchInput) { + searchInput.setAttribute('data-i18n', 'projects.searchProjectsPlaceholder'); + searchInput.setAttribute('data-i18n-attr', 'placeholder'); + searchInput.setAttribute('oninput', 'handleProjectFolderSearch(this.value)'); + searchInput.setAttribute('onkeypress', "if(event.key === 'Enter') handleProjectFolderSearch(this.value)"); + searchInput.placeholder = conversationSidebarText('projects.searchProjectsPlaceholder', '搜索项目…'); + } + const searchClear = document.getElementById('conversation-search-clear'); + if (searchClear) searchClear.setAttribute('onclick', 'clearProjectFolderSearch()'); + + const projectFilter = sidebarContent.querySelector('.conversation-project-filter'); + if (projectFilter) projectFilter.hidden = true; + + let projectSection = sidebarContent.querySelector('.project-folders-section'); + const legacyTaskSection = sidebarContent.querySelector('.task-folders-section'); + if (!projectSection && legacyTaskSection) { + projectSection = legacyTaskSection; + projectSection.className = 'project-folders-section'; + projectSection.setAttribute('aria-labelledby', 'project-folders-title'); + const legacyHeader = projectSection.querySelector('.task-folders-header'); + if (legacyHeader) legacyHeader.className = 'section-header project-folders-header'; + const legacyTitle = projectSection.querySelector('#task-folders-title'); + if (legacyTitle) { + legacyTitle.id = 'project-folders-title'; + legacyTitle.setAttribute('data-i18n', 'chat.projectFolders'); + legacyTitle.textContent = conversationSidebarText('chat.projectFolders', '项目'); + } + const legacyList = projectSection.querySelector('#task-folders-list'); + if (legacyList) { + legacyList.id = 'project-folders-list'; + legacyList.className = 'project-folders-list'; + legacyList.removeAttribute('role'); + legacyList.innerHTML = ''; + } + } + if (!projectSection) { + projectSection = document.createElement('section'); + projectSection.className = 'project-folders-section'; + projectSection.setAttribute('aria-labelledby', 'project-folders-title'); + projectSection.innerHTML = + '
' + + '项目' + + '' + + '
' + + '
'; + const searchBox = sidebarContent.querySelector('.conversation-search-box'); + if (searchBox) searchBox.insertAdjacentElement('afterend', projectSection); + else sidebarContent.insertBefore(projectSection, sidebarContent.firstChild); + } + + const projectHeader = projectSection.querySelector('.project-folders-header'); + if (projectHeader && !projectHeader.querySelector('.project-folders-add-btn')) { + const addProjectButton = document.createElement('button'); + addProjectButton.type = 'button'; + addProjectButton.className = 'add-group-btn project-folders-add-btn'; + addProjectButton.dataset.requirePermission = 'project:write'; + addProjectButton.setAttribute('onclick', 'showNewProjectModalFromChatSidebar()'); + addProjectButton.setAttribute('data-i18n', 'projects.newProject'); + addProjectButton.setAttribute('data-i18n-attr', 'title,aria-label'); + addProjectButton.setAttribute('data-i18n-skip-text', 'true'); + addProjectButton.title = conversationSidebarText('projects.newProject', '新建项目'); + addProjectButton.setAttribute('aria-label', addProjectButton.title); + addProjectButton.innerHTML = ''; + projectHeader.appendChild(addProjectButton); + } + + const recentSection = sidebarContent.querySelector('.recent-conversations-section'); + if (recentSection) { + recentSection.id = 'recent-conversations-section'; + recentSection.classList.add('is-collapsed'); + let toggle = document.getElementById('recent-conversations-toggle'); + let body = document.getElementById('recent-conversations-body'); + if (!toggle) { + const oldHeader = recentSection.querySelector(':scope > .section-header'); + const title = oldHeader && oldHeader.querySelector('.section-title'); + const actions = oldHeader && oldHeader.querySelector('.section-header-actions'); + const list = recentSection.querySelector('#conversations-list'); + + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.id = 'recent-conversations-toggle'; + toggle.className = 'section-header recent-conversations-toggle'; + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-controls', 'recent-conversations-body'); + toggle.setAttribute('data-i18n', 'chat.toggleRecentConversations'); + toggle.setAttribute('data-i18n-attr', 'title,aria-label'); + toggle.setAttribute('data-i18n-skip-text', 'true'); + toggle.title = conversationSidebarText('chat.toggleRecentConversations', '展开/折叠最近对话'); + toggle.setAttribute('aria-label', toggle.title); + toggle.addEventListener('click', toggleRecentConversations); + if (title) toggle.appendChild(title); + else toggle.innerHTML = '最近对话'; + + const meta = document.createElement('span'); + meta.className = 'recent-conversations-toggle-meta'; + meta.innerHTML = + '0' + + ''; + toggle.appendChild(meta); + + body = document.createElement('div'); + body.id = 'recent-conversations-body'; + body.className = 'recent-conversations-body'; + body.hidden = true; + if (actions) { + actions.classList.add('recent-conversations-actions'); + body.appendChild(actions); + } + if (list) body.appendChild(list); + if (oldHeader) oldHeader.remove(); + recentSection.prepend(toggle); + recentSection.appendChild(body); + } + } + + if (typeof window.applyTranslations === 'function') { + window.applyTranslations(sidebar); + } +} + +function setRecentConversationsExpanded(expanded, options = {}) { + const section = document.getElementById('recent-conversations-section'); + const toggle = document.getElementById('recent-conversations-toggle'); + const body = document.getElementById('recent-conversations-body'); + const pagination = document.getElementById('conversations-pagination'); + const open = !!expanded; + if (section) section.classList.toggle('is-collapsed', !open); + if (toggle) toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (body) body.hidden = !open; + if (pagination) pagination.hidden = !open; + if (options.persist !== false) { + try { + localStorage.setItem(RECENT_CONVERSATIONS_EXPANDED_KEY, open ? '1' : '0'); + } catch (e) { /* ignore */ } + } +} + +function restoreRecentConversationsState() { + let expanded = false; + try { + expanded = localStorage.getItem(RECENT_CONVERSATIONS_EXPANDED_KEY) === '1'; + } catch (e) { /* ignore */ } + setRecentConversationsExpanded(expanded, { persist: false }); +} + +function toggleRecentConversations() { + const toggle = document.getElementById('recent-conversations-toggle'); + const expanded = toggle && toggle.getAttribute('aria-expanded') === 'true'; + setRecentConversationsExpanded(!expanded); +} + +function updateRecentConversationsCount(total) { + const count = document.getElementById('recent-conversations-count'); + if (count) count.textContent = String(Math.max(0, Number(total) || 0)); +} + +if (typeof window !== 'undefined') { + window.toggleRecentConversations = toggleRecentConversations; + window.setRecentConversationsExpanded = setRecentConversationsExpanded; +} + function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) { if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { return ''; @@ -4601,15 +5849,38 @@ async function prefetchLastAssistantProcessDetails() { } async function loadConversation(conversationId) { + // Keep the visible conversation addressable across a full page refresh. + // Sidebar/project entries call loadConversation directly (rather than the + // router helper), so without this synchronization #chat loses the active + // conversation and reload falls back to the welcome screen instead of + // reconnecting the running task event stream. + syncChatConversationHash(conversationId); const seq = ++loadConversationRequestSeq; + const previousConversationId = currentConversationId; + cancelPendingConversationLoad(); + detachLiveChatStreamForNavigation(conversationId); + const conversationLoadController = new AbortController(); + loadConversationAbortController = conversationLoadController; + if (typeof window.selectChatProjectConversationItem === 'function') { + window.selectChatProjectConversationItem(conversationId); + } + if (typeof window.cancelRunningTaskEventStream === 'function') { + window.cancelRunningTaskEventStream(conversationId); + } + if (typeof window.clearChatHitlApprovalDock === 'function') { + window.clearChatHitlApprovalDock(); + } try { const cachedConversation = getConversationLiteFromCache(conversationId); let conversation = null; let response = null; try { - response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`); + response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`, { + signal: conversationLoadController.signal + }); conversation = await response.json(); } catch (fetchError) { + if (fetchError && fetchError.name === 'AbortError') return; if (!cachedConversation) throw fetchError; console.warn('加载最新对话失败,使用本地缓存:', fetchError); conversation = cachedConversation; @@ -4674,8 +5945,9 @@ async function loadConversation(conversationId) { try { window.currentConversationId = conversationId; } catch (e) { /* ignore */ } + updateChatPrimaryActionState(); if (typeof refreshChatProjectSelector === 'function') { - refreshChatProjectSelector(); + refreshChatProjectSelector({ reloadFolders: false, renderFolders: false }); } refreshHitlConfigByCurrentConversation(); const hitlSyncPromise = (typeof window.syncHitlConfigFromServer === 'function') @@ -4737,15 +6009,14 @@ async function loadConversation(conversationId) { if (msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) { return; } + const assistantContent = String(msg && msg.content != null ? msg.content : '').trim(); + const terminalState = msg && msg.role === 'assistant' + ? assistantTurnTerminalState(msg.processDetails) + : null; let displayContent = msg.content; - if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) { - for (let i = msg.processDetails.length - 1; i >= 0; i--) { - const detail = msg.processDetails[i]; - if (detail.eventType === 'error' || detail.eventType === 'cancelled') { - displayContent = detail.message || msg.content; - break; - } - } + if (msg.role === 'assistant' && + (assistantContent === '处理中...' || assistantContent === 'Processing...') && terminalState) { + displayContent = terminalState.detail.message || msg.content; } // 消息时间口径: @@ -4753,7 +6024,15 @@ async function loadConversation(conversationId) { // - assistant: 如果后端提供 updatedAt(任务完成时写回),优先用它,避免占位消息“任务开始时间”误导 const msgTime = (msg && msg.role === 'assistant' && msg.updatedAt) ? msg.updatedAt : (msg ? msg.createdAt : null); const mcpIds = (msg.mcpExecutionIds && Array.isArray(msg.mcpExecutionIds)) ? msg.mcpExecutionIds : []; - const addOpts = (msg.role === 'assistant' && mcpIds.length > 0) ? { deferMcpButtons: true } : null; + const isAssistantPlaceholder = msg.role === 'assistant' && ( + assistantContent === '处理中...' || assistantContent === 'Processing...' + ); + const addOpts = (msg.role === 'assistant' && (mcpIds.length > 0 || isAssistantPlaceholder)) + ? { + deferMcpButtons: mcpIds.length > 0, + hideAssistantPlaceholder: isAssistantPlaceholder + } + : null; const messageId = addMessage(msg.role, displayContent, mcpIds, null, msgTime, addOpts); const messageEl = document.getElementById(messageId); if (messageEl && msg && msg.id) { @@ -4761,6 +6040,24 @@ async function loadConversation(conversationId) { attachDeleteTurnButton(messageEl); } if (msg.role === 'assistant') { + if (messageEl && typeof window.setAssistantTurnTiming === 'function') { + const startedAt = msg && msg.createdAt ? msg.createdAt : null; + const completedAt = terminalState && terminalState.completedAt + ? terminalState.completedAt + : (msg && msg.updatedAt ? msg.updatedAt : startedAt); + const startedMs = assistantTurnTimestamp(startedAt); + const completedMs = assistantTurnTimestamp(completedAt); + const isRunning = isAssistantPlaceholder && !terminalState; + const status = terminalState ? terminalState.status : (isRunning ? 'running' : 'completed'); + window.setAssistantTurnTiming(messageEl, { + startedAt: startedAt, + completedAt: isRunning ? null : completedAt, + durationMs: (!isRunning && Number.isFinite(startedMs) && Number.isFinite(completedMs)) + ? Math.max(0, completedMs - startedMs) + : undefined, + status: status + }); + } if (messageEl && msg.reasoningContent) { setMessageReasoningContent(messageEl, msg.reasoningContent); } @@ -4828,9 +6125,14 @@ async function loadConversation(conversationId) { if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') { await window.restoreHitlInlineForConversation(conversationId); } + if ( + window.CyberStrikeChatScroll && + typeof window.CyberStrikeChatScroll.settleConversationRestoreToBottom === 'function' + ) { + window.CyberStrikeChatScroll.settleConversationRestoreToBottom(30); + } } else { - const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; - addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true, scroll: 'force' }); + renderChatWelcomeEmptyState(); if (window.CyberStrikeChatScroll) { window.CyberStrikeChatScroll.forceScrollToBottom(false); } else { @@ -4866,8 +6168,19 @@ async function loadConversation(conversationId) { }); } } catch (error) { + if (error && error.name === 'AbortError') return; + if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') { + window.selectChatProjectConversationItem(previousConversationId); + } console.error('加载对话失败:', error); showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error'); + } finally { + if (seq === loadConversationRequestSeq && typeof window.finishChatConversationRestore === 'function') { + window.finishChatConversationRestore(conversationId); + } + if (loadConversationAbortController === conversationLoadController) { + loadConversationAbortController = null; + } } } @@ -4962,8 +6275,7 @@ async function deleteConversation(conversationId, skipConfirm = false) { window.currentConversationId = ''; } catch (e) { /* ignore */ } document.getElementById('chat-messages').innerHTML = ''; - const readyMsgLoad = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; - addMessage('assistant', readyMsgLoad, null, null, null, { systemReadyMessage: true }); + renderChatWelcomeEmptyState(); addAttackChainButton(null); } @@ -4972,6 +6284,12 @@ async function deleteConversation(conversationId, skipConfirm = false) { invalidateConversationLiteCache(conversationId); // 同时从待保留映射中移除 delete pendingGroupMappings[conversationId]; + + // 先同步所有侧栏的本地状态,再执行网络刷新。项目文件夹使用独立的 + // conversation cache;如果只刷新“最近对话”,删除项会一直残留到整页刷新。 + try { + document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } })); + } catch (e) { /* ignore */ } // 如果当前在分组详情页面,重新加载分组对话 if (currentGroupId) { @@ -4992,10 +6310,6 @@ async function deleteConversation(conversationId, skipConfirm = false) { applyBatchConversationFilters(); } - // 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致 - try { - document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } })); - } catch (e) { /* ignore */ } } catch (error) { console.error('删除对话失败:', error); alert('删除对话失败: ' + error.message); @@ -7210,6 +8524,7 @@ function exportAttackChain(format) { let currentGroupId = null; // 当前正在查看的分组详情页面 let currentConversationGroupId = null; // 当前对话所属的分组ID(用于高亮显示) let contextMenuConversationId = null; +let contextMenuConversationTitle = ''; let contextMenuGroupId = null; let groupsCache = []; let conversationGroupMappingCache = {}; @@ -8036,7 +9351,8 @@ function renderConversationsPagination(visibleCount) { const totalPages = getConversationsTotalPages(); const navDisabled = totalPages <= 1; - el.hidden = false; + const recentToggle = document.getElementById('recent-conversations-toggle'); + el.hidden = !recentToggle || recentToggle.getAttribute('aria-expanded') !== 'true'; const start = total === 0 ? 0 : (page - 1) * pageSize + 1; const end = Math.min(page * pageSize, total); const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; @@ -8175,8 +9491,11 @@ async function loadGroups() { groupItem.appendChild(content); const menuBtn = document.createElement('button'); + menuBtn.type = 'button'; menuBtn.className = 'group-item-menu'; menuBtn.innerHTML = '⋯'; + menuBtn.title = typeof window.t === 'function' ? window.t('common.actions') : '操作'; + menuBtn.setAttribute('aria-label', menuBtn.title); menuBtn.onclick = (e) => { e.stopPropagation(); showGroupContextMenu(e, group.id); @@ -8246,6 +9565,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { if (!response.ok) { listContainer.innerHTML = emptyStateHtml; if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + updateRecentConversationsCount(0); renderConversationsPagination(0); return; } @@ -8256,6 +9576,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { const resolvedTotal = await resolveConversationsListTotal(convParams, parsed, pageSize, offset); if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return; conversationsPagination.total = resolvedTotal; + updateRecentConversationsCount(resolvedTotal); const pageCheck = reconcileConversationsPageAfterTotal( activePage, intentPage, parsed, pageSize, offset, resolvedTotal @@ -8443,6 +9764,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { if (listContainer) { listContainer.innerHTML = getConversationListEmptyHtml(); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + updateRecentConversationsCount(0); renderConversationsPagination(0); } } @@ -8513,11 +9835,7 @@ function createConversationListItemWithMenu(conversation, isPinned) { const menuBtn = document.createElement('button'); menuBtn.className = 'conversation-item-menu'; menuBtn.innerHTML = '⋯'; - menuBtn.onclick = (e) => { - e.stopPropagation(); - contextMenuConversationId = conversation.id; - showConversationContextMenu(e); - }; + menuBtn.onclick = (e) => openConversationContextMenuForId(e, conversation.id, conversation.title || ''); item.appendChild(menuBtn); item.onclick = (e) => { @@ -8532,6 +9850,14 @@ function createConversationListItemWithMenu(conversation, isPinned) { return item; } +function openConversationContextMenuForId(event, conversationId, conversationTitle = '') { + event.stopPropagation(); + event.preventDefault(); + contextMenuConversationId = conversationId; + contextMenuConversationTitle = conversationTitle; + return showConversationContextMenu(event); +} + // 显示对话上下文菜单 async function showConversationContextMenu(event) { const menu = document.getElementById('conversation-context-menu'); @@ -8821,17 +10147,93 @@ async function showGroupContextMenu(event, groupId) { }, 0); } -// 重命名对话 -async function renameConversation() { +let renameConversationTargetId = null; + +function ensureConversationRenameModal() { + let modal = document.getElementById('conversation-rename-modal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'conversation-rename-modal'; + modal.className = 'modal-overlay projects-modal-overlay'; + modal.style.display = 'none'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'conversation-rename-title'); + modal.innerHTML = ` +
+
+
+
+

重命名对话

+

修改后会同步更新项目文件夹和最近对话中的名称

+
+
+ +
+
+
+ + +
+
+ +
`; + modal.addEventListener('click', (event) => { + if (event.target === modal) closeConversationRenameModal(); + }); + modal.querySelectorAll('[data-conversation-rename-close]').forEach((button) => { + button.addEventListener('click', closeConversationRenameModal); + }); + modal.querySelector('#conversation-rename-submit')?.addEventListener('click', saveConversationRename); + modal.querySelector('#conversation-rename-input')?.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + saveConversationRename(); + } else if (event.key === 'Escape') { + closeConversationRenameModal(); + } + }); + document.body.appendChild(modal); + if (typeof window.applyTranslations === 'function') window.applyTranslations(modal); + return modal; +} + +// 打开应用内重命名弹窗,避免内置浏览器拦截 window.prompt。 +function renameConversation() { const convId = contextMenuConversationId; if (!convId) return; - const newTitle = prompt('请输入新标题:', ''); - if (newTitle === null || !newTitle.trim()) { - closeContextMenu(); + renameConversationTargetId = convId; + const currentTitle = contextMenuConversationTitle || ''; + ensureConversationRenameModal(); + const input = document.getElementById('conversation-rename-input'); + if (input) input.value = currentTitle; + closeContextMenu(); + openAppModal('conversation-rename-modal', { focusEl: input }); + if (input) input.select(); +} + +function closeConversationRenameModal() { + renameConversationTargetId = null; + closeAppModal('conversation-rename-modal'); +} + +async function saveConversationRename() { + const convId = renameConversationTargetId; + const input = document.getElementById('conversation-rename-input'); + const newTitle = (input?.value || '').trim(); + if (!convId || !newTitle) { + input?.focus(); return; } + const submitButton = document.getElementById('conversation-rename-submit'); + if (submitButton) submitButton.disabled = true; + try { const response = await apiFetch(`/api/conversations/${convId}`, { method: 'PUT', @@ -8847,13 +10249,14 @@ async function renameConversation() { } // 更新前端显示 - const item = document.querySelector(`[data-conversation-id="${convId}"]`); - if (item) { - const titleEl = item.querySelector('.conversation-title'); - if (titleEl) { - titleEl.textContent = newTitle.trim(); - } - } + document.querySelectorAll('[data-conversation-id]').forEach((item) => { + if (item.dataset.conversationId !== convId) return; + item.querySelectorAll('.conversation-title, .group-conversation-title, .project-conversation-title') + .forEach((titleEl) => { + titleEl.textContent = newTitle.trim(); + titleEl.title = newTitle.trim(); + }); + }); // 如果在分组详情页,也需要更新 const groupItem = document.querySelector(`.group-conversation-item[data-conversation-id="${convId}"]`); @@ -8870,15 +10273,36 @@ async function renameConversation() { } // 重新加载对话列表 - loadConversationsWithGroups(); + await loadConversationsWithGroups(); + if (typeof window.refreshChatProjectFolders === 'function') { + await window.refreshChatProjectFolders(); + } + closeConversationRenameModal(); } catch (error) { console.error('重命名对话失败:', error); const failedLabel = typeof window.t === 'function' ? window.t('chat.renameFailed') : '重命名失败'; const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; alert(failedLabel + ': ' + (error.message || unknownErr)); + } finally { + if (submitButton) submitButton.disabled = false; } +} - closeContextMenu(); +async function assertConversationActionResponse(response, fallbackMessage) { + if (response && response.ok) return response; + let payload = {}; + try { + payload = response ? await response.json() : {}; + } catch (e) { /* ignore */ } + throw new Error(payload.error || payload.message || fallbackMessage); +} + +function notifyConversationPinnedChanged(conversationId, pinned) { + try { + document.dispatchEvent(new CustomEvent('conversation-pinned-changed', { + detail: { conversationId, pinned: !!pinned } + })); + } catch (e) { /* ignore */ } } // 置顶对话 @@ -8886,6 +10310,9 @@ async function pinConversation() { const convId = contextMenuConversationId; if (!convId) return; + // 点击后立即收起菜单,避免网络请求期间看起来“没有反应”。 + closeContextMenu(); + try { // 检查对话是否真的在当前分组中 // 如果对话已经从分组移出,conversationGroupMappingCache 中不会有该对话的映射 @@ -8897,6 +10324,7 @@ async function pinConversation() { if (isInCurrentGroup) { // 获取当前对话在分组中的置顶状态 const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); + await assertConversationActionResponse(response, '获取分组对话失败'); const groupConvs = await response.json(); const conv = groupConvs.find(c => c.id === convId); @@ -8905,31 +10333,37 @@ async function pinConversation() { const newPinned = !currentPinned; // 更新分组内置顶状态 - await apiFetch(`/api/groups/${currentGroupId}/conversations/${convId}/pinned`, { + const updateResponse = await apiFetch(`/api/groups/${currentGroupId}/conversations/${convId}/pinned`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ pinned: newPinned }), }); + await assertConversationActionResponse(updateResponse, '更新分组内置顶状态失败'); // 重新加载分组对话 - loadGroupConversations(currentGroupId); + await loadGroupConversations(currentGroupId); } else { // 不在分组详情页面,或者对话不在当前分组中,使用全局置顶 const response = await apiFetch(`/api/conversations/${convId}`); + await assertConversationActionResponse(response, '获取对话失败'); const conv = await response.json(); const newPinned = !conv.pinned; // 更新全局置顶状态 - await apiFetch(`/api/conversations/${convId}/pinned`, { + const updateResponse = await apiFetch(`/api/conversations/${convId}/pinned`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ pinned: newPinned }), }); + await assertConversationActionResponse(updateResponse, '更新置顶状态失败'); + // 项目文件夹侧栏与“最近对话”使用不同缓存;先发事件做即时更新, + // projects.js 再后台拉取服务端数据校准。 + notifyConversationPinnedChanged(convId, newPinned); loadConversationsWithGroups(); } } catch (error) { @@ -8937,7 +10371,6 @@ async function pinConversation() { alert('置顶失败: ' + (error.message || '未知错误')); } - closeContextMenu(); } // 显示移动到分组子菜单 @@ -9565,6 +10998,7 @@ function closeContextMenu() { clearDownloadMarkdownSubmenuHideTimeout(); submenuLoading = false; contextMenuConversationId = null; + contextMenuConversationTitle = ''; } // 显示批量管理模态框 @@ -10156,9 +11590,6 @@ function refreshChatPanelI18n() { } } catch (e) { /* ignore */ } }); - messagesEl.querySelectorAll('.mcp-call-label').forEach(function (el) { - el.textContent = '\uD83D\uDCCB ' + t('chat.penetrationTestDetail'); - }); messagesEl.querySelectorAll('.process-detail-btn').forEach(function (btn) { const span = btn.querySelector('span'); if (!span) return; @@ -10179,10 +11610,16 @@ function refreshChatPanelI18n() { btn.setAttribute('aria-label', copyTitle); }); messagesEl.querySelectorAll('.message.assistant').forEach(function (msgEl) { + if (typeof window.syncAssistantTurnSummary === 'function') { + window.syncAssistantTurnSummary(msgEl); + } if (typeof window.syncMcpToolsToggleButton === 'function') { window.syncMcpToolsToggleButton(msgEl); } }); + if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.refreshTurnRail === 'function') { + window.CyberStrikeChatScroll.refreshTurnRail(); + } } if (isAppModalOpen('mcp-detail-modal')) { @@ -10354,8 +11791,10 @@ function applyCustomIcon() { // 自定义图标输入框回车键处理 document.addEventListener('DOMContentLoaded', function() { + mountChatSessionSettingsPopover(); initSessionSettingsSelects(); initChatReasoningBarHeightSync(); + initChatPrimaryActionButton(); const customInput = document.getElementById('custom-icon-input'); if (customInput) { customInput.addEventListener('keydown', function(e) { @@ -10376,6 +11815,11 @@ document.addEventListener('DOMContentLoaded', function() { document.addEventListener('languagechange', function () { refreshHitlConfigByCurrentConversation(); + updateChatPrimaryActionState(); +}); + +document.addEventListener('keydown', function (event) { + if (event.key === 'Escape') closeChatSystemModelPicker(); }); // 点击外部关闭图标选择器、对话模式面板、侧栏折叠卡片 @@ -10397,6 +11841,12 @@ document.addEventListener('click', function(event) { } } + const modelWrap = document.getElementById('chat-model-shortcut-wrap'); + const modelMenu = document.getElementById('chat-system-model-menu'); + if (modelWrap && modelMenu && !modelMenu.hidden && !modelWrap.contains(event.target)) { + closeChatSystemModelPicker(); + } + const reasoningWrap = document.getElementById('chat-reasoning-wrapper'); if (reasoningWrap && reasoningWrap.style.display !== 'none' && !reasoningWrap.classList.contains('conversation-reasoning-collapsed')) { @@ -10752,11 +12202,7 @@ async function loadGroupConversations(groupId, searchQuery = '') { const menuBtn = document.createElement('button'); menuBtn.className = 'conversation-item-menu'; menuBtn.innerHTML = '⋯'; - menuBtn.onclick = (e) => { - e.stopPropagation(); - contextMenuConversationId = conv.id; - showConversationContextMenu(e); - }; + menuBtn.onclick = (e) => openConversationContextMenuForId(e, conv.id, fullConv.title || conv.title || ''); item.appendChild(menuBtn); item.onclick = (e) => { @@ -10847,48 +12293,63 @@ async function editGroup() { } } -// 删除分组 -async function deleteGroup() { - if (typeof requirePermission === 'function' && !requirePermission('group:delete')) return; - if (!currentGroupId) return; +function removeConversationGroupFromLocalState(groupId) { + groupsCache = groupsCache.filter(group => group.id !== groupId); + Object.keys(conversationGroupMappingCache).forEach(convId => { + if (conversationGroupMappingCache[convId] === groupId) { + delete conversationGroupMappingCache[convId]; + } + }); + document.querySelectorAll('.group-item[data-group-id]').forEach(item => { + if (item.dataset.groupId === groupId) item.remove(); + }); +} + +async function deleteConversationGroupById(groupId, options = {}) { + if (typeof requirePermission === 'function' && !requirePermission('group:delete')) { + if (options.closeContextMenu) closeGroupContextMenu(); + return; + } + if (!groupId) return; const deleteConfirmMsg = typeof window.t === 'function' ? window.t('chat.deleteGroupConfirm') : '确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。'; if (!confirm(deleteConfirmMsg)) { + if (options.closeContextMenu) closeGroupContextMenu(); return; } try { - await apiFetch(`/api/groups/${currentGroupId}`, { + const deleteResponse = await apiFetch(`/api/groups/${groupId}`, { method: 'DELETE', }); + await assertConversationActionResponse(deleteResponse, '删除分组失败'); - // 更新缓存 - groupsCache = groupsCache.filter(g => g.id !== currentGroupId); - Object.keys(conversationGroupMappingCache).forEach(convId => { - if (conversationGroupMappingCache[convId] === currentGroupId) { - delete conversationGroupMappingCache[convId]; - } - }); + // 删除成功后先同步本地界面,再做服务端列表校准。 + removeConversationGroupFromLocalState(groupId); + if (currentGroupId === groupId) exitGroupDetail(); // 如果"移动到分组"子菜单是打开的,刷新它 const submenu = document.getElementById('move-to-group-submenu'); + await loadGroups(); if (submenu && submenu.style.display !== 'none') { - // 子菜单是打开的,重新加载分组列表并刷新子菜单 - await loadGroups(); await showMoveToGroupSubmenu(); - } else { - exitGroupDetail(); - await loadGroups(); } - + // 刷新对话列表,确保之前被分组的对话能立即显示 await loadConversationsWithGroups(); } catch (error) { console.error('删除分组失败:', error); alert('删除失败: ' + (error.message || '未知错误')); + } finally { + if (options.closeContextMenu) closeGroupContextMenu(); } } +// 删除当前分组详情中的分组 +async function deleteGroup() { + await deleteConversationGroupById(currentGroupId); +} + // 从上下文菜单重命名分组 async function renameGroupFromContext() { const groupId = contextMenuGroupId; @@ -11008,51 +12469,9 @@ async function pinGroupFromContext() { // 从上下文菜单删除分组 async function deleteGroupFromContext() { - if (typeof requirePermission === 'function' && !requirePermission('group:delete')) return; const groupId = contextMenuGroupId; if (!groupId) return; - - const deleteConfirmMsg = typeof window.t === 'function' ? window.t('chat.deleteGroupConfirm') : '确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。'; - if (!confirm(deleteConfirmMsg)) { - closeGroupContextMenu(); - return; - } - - try { - await apiFetch(`/api/groups/${groupId}`, { - method: 'DELETE', - }); - - // 更新缓存 - groupsCache = groupsCache.filter(g => g.id !== groupId); - Object.keys(conversationGroupMappingCache).forEach(convId => { - if (conversationGroupMappingCache[convId] === groupId) { - delete conversationGroupMappingCache[convId]; - } - }); - - // 如果"移动到分组"子菜单是打开的,刷新它 - const submenu = document.getElementById('move-to-group-submenu'); - if (submenu && submenu.style.display !== 'none') { - // 子菜单是打开的,重新加载分组列表并刷新子菜单 - await loadGroups(); - await showMoveToGroupSubmenu(); - } else { - // 如果当前在分组详情页,退出详情页 - if (currentGroupId === groupId) { - exitGroupDetail(); - } - await loadGroups(); - } - - // 刷新对话列表,确保之前被分组的对话能立即显示 - await loadConversationsWithGroups(); - } catch (error) { - console.error('删除分组失败:', error); - alert('删除失败: ' + (error.message || '未知错误')); - } - - closeGroupContextMenu(); + await deleteConversationGroupById(groupId, { closeContextMenu: true }); } // 关闭分组上下文菜单 @@ -11157,7 +12576,14 @@ function clearGroupSearch() { // 初始化时加载分组 document.addEventListener('DOMContentLoaded', async () => { + ensureProjectSidebarStructure(); if (window.i18nReady) await window.i18nReady; + if (typeof window.applyTranslations === 'function') { + window.applyTranslations(document.getElementById('conversation-sidebar')); + } + // 任务栏不再暴露项目筛选,清除旧选择以免隐藏部分任务。 + setConversationProjectFilter(''); + restoreRecentConversationsState(); updateConversationSortMenuUI(); initConversationProjectCustomSelect(); initConversationsPaginationEvents(); @@ -11199,6 +12625,11 @@ document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('conversation-deleted', (e) => { const id = e.detail && e.detail.conversationId; if (!id) return; + // API 已确认删除后立即移除可见列表项,网络刷新只负责校准分页和计数。 + document.querySelectorAll('.conversation-item[data-conversation-id], .group-conversation-item[data-conversation-id]') + .forEach((item) => { + if (item.dataset.conversationId === id) item.remove(); + }); if (id === currentConversationId) { currentConversationId = null; try { @@ -11206,8 +12637,7 @@ document.addEventListener('DOMContentLoaded', async () => { } catch (e) { /* ignore */ } const messagesDiv = document.getElementById('chat-messages'); if (messagesDiv) messagesDiv.innerHTML = ''; - const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; - addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true }); + renderChatWelcomeEmptyState(); addAttackChainButton(null); } if (typeof loadConversationsWithGroups === 'function') { @@ -11227,6 +12657,11 @@ async function refreshAllProjectFilterSelects() { if (typeof window !== 'undefined') { window.loadConversation = loadConversation; window.startNewConversation = startNewConversation; + window.refreshChatWelcomeEmptyState = refreshSystemReadyMessageBubbles; + window.openConversationContextMenuForId = openConversationContextMenuForId; + window.renameConversation = renameConversation; + window.closeConversationRenameModal = closeConversationRenameModal; + window.saveConversationRename = saveConversationRename; window.refreshConversationProjectFilter = refreshConversationProjectFilter; window.refreshAllProjectFilterSelects = refreshAllProjectFilterSelects; window.onConversationProjectFilterChange = onConversationProjectFilterChange; diff --git a/web/static/js/conversation-actions-sync.test.cjs b/web/static/js/conversation-actions-sync.test.cjs new file mode 100644 index 00000000..afc66cd3 --- /dev/null +++ b/web/static/js/conversation-actions-sync.test.cjs @@ -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 \}\)/); +}); diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs new file mode 100644 index 00000000..ce858a4e --- /dev/null +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -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'); + }); +}); diff --git a/web/static/js/hitl-conversation-isolation.test.cjs b/web/static/js/hitl-conversation-isolation.test.cjs new file mode 100644 index 00000000..f9483acf --- /dev/null +++ b/web/static/js/hitl-conversation-isolation.test.cjs @@ -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\)/); +}); diff --git a/web/static/js/hitl.js b/web/static/js/hitl.js index 7608f94d..95694db5 100644 --- a/web/static/js/hitl.js +++ b/web/static/js/hitl.js @@ -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; diff --git a/web/static/js/iteration-divider-ui.test.cjs b/web/static/js/iteration-divider-ui.test.cjs new file mode 100644 index 00000000..06f9f755 --- /dev/null +++ b/web/static/js/iteration-divider-ui.test.cjs @@ -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\)/); +}); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 457f27eb..18eb5ca6 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -2,9 +2,14 @@ const progressTaskState = new Map(); /** @type {{ progressId: string, conversationId: string } | null} */ let userInterruptModalPending = null; let activeTaskInterval = null; -const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次 +const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时自刷新 const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']); const hitlInterruptToolItemMap = new Map(); +let activeTasksLoadPromise = null; +const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1'; +let chatTaskSyncChannel = null; +let visibleConversationReplaySyncPromise = null; +let visibleConversationReplaySyncId = ''; /** * 主对话 POST 流仍在读取时,禁止再挂 task-events 补流,否则同一事件会画两遍(与 HITL 是否开启无关)。 @@ -17,6 +22,9 @@ function syncAgentLiveStreamConversationId(cid) { if (live && live.active) { live.conversationId = cid; } + if (typeof window.updateChatPrimaryActionState === 'function') { + window.updateChatPrimaryActionState(); + } } catch (e) { /* ignore */ } } @@ -24,6 +32,9 @@ function setCurrentConversationIdFromStream(cid) { currentConversationId = cid; try { window.currentConversationId = cid; + if (typeof window.syncChatConversationHash === 'function') { + window.syncChatConversationHash(cid); + } } catch (e) { /* ignore */ } } @@ -520,6 +531,43 @@ function shouldReuseMainResponseStream(progressId, prevStream, responseData, str return areMainResponseStreamIterationsCompatible(prevIterTag, streamIterTag, orch); } +/** 刷新后从数据库恢复的 planning 行,继续复用为当前 response_stream 容器。 */ +function findRestoredMainResponseStreamItem(timeline, responseData) { + if (!timeline) return null; + const data = responseData || {}; + const streamId = data.streamId != null ? String(data.streamId).trim() : ''; + const agent = data.einoAgent != null ? String(data.einoAgent).trim() : ''; + const orchestration = data.orchestration != null ? String(data.orchestration).trim() : ''; + const items = timeline.querySelectorAll('.timeline-item-planning, .timeline-item-thinking'); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + const itemStreamId = String(item.dataset.responseStreamId || '').trim(); + if (streamId) { + if (itemStreamId === streamId) return item; + continue; + } + const itemAgent = String(item.dataset.einoAgent || '').trim(); + const itemOrchestration = String(item.dataset.orchestration || '').trim(); + if (agent && itemAgent === agent && itemOrchestration === orchestration) return item; + } + return null; +} + +function responseStreamStateFromRestoredItem(progressId, item, responseData) { + if (!item) return null; + const data = responseData || {}; + const contentEl = item.querySelector('.timeline-item-content'); + item.dataset.responseStreamPlaceholder = '1'; + return { + progressId: progressId, + itemId: item.id, + buffer: contentEl ? String(contentEl.textContent || '') : '', + streamMeta: data, + streamIdentity: buildMainResponseStreamIdentity(progressId, data), + streamId: data.streamId != null ? String(data.streamId).trim() : '' + }; +} + // AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer }) const thinkingStreamStateByProgressId = new Map(); @@ -999,6 +1047,11 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow if (copyBtn) copyBtn.remove(); const newContent = content == null ? '' : String(content); + const normalizedContent = newContent.trim(); + if (normalizedContent !== '处理中...' && normalizedContent !== 'Processing...') { + assistantElement.classList.remove('assistant-placeholder-content'); + bubble.hidden = false; + } const html = renderMarkdown ? formatAssistantMarkdownContent(newContent) : escapeHtmlLocal(newContent).replace(/\n/g, '
'); @@ -1020,6 +1073,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow const conversationExecutionTracker = { activeConversations: new Set(), + ready: false, update(tasks = []) { this.activeConversations.clear(); tasks.forEach(task => { @@ -1031,9 +1085,85 @@ const conversationExecutionTracker = { this.activeConversations.add(task.conversationId); } }); + this.ready = true; }, isRunning(conversationId) { return !!conversationId && this.activeConversations.has(conversationId); + }, + markRunning(conversationId) { + const id = String(conversationId || '').trim(); + if (!id) return false; + this.activeConversations.add(id); + this.ready = true; + return true; + } +}; + +function notifyConversationTaskStarted(conversationId) { + const id = String(conversationId || '').trim(); + if (!id) return false; + conversationExecutionTracker.markRunning(id); + if (typeof window.updateChatPrimaryActionState === 'function') { + window.updateChatPrimaryActionState(); + } + if (chatTaskSyncChannel) { + chatTaskSyncChannel.postMessage({ type: 'task-started', conversationId: id, at: Date.now() }); + } + return true; +} + +function initChatTaskSyncChannel() { + if (chatTaskSyncChannel || typeof BroadcastChannel !== 'function') return; + chatTaskSyncChannel = new BroadcastChannel(CHAT_TASK_SYNC_CHANNEL_NAME); + chatTaskSyncChannel.addEventListener('message', function (event) { + const payload = event && event.data; + if (!payload || payload.type !== 'task-started') return; + const id = String(payload.conversationId || '').trim(); + if (!id) return; + conversationExecutionTracker.markRunning(id); + if (typeof window.updateChatPrimaryActionState === 'function') { + window.updateChatPrimaryActionState(); + } + // 服务端任务注册可能比跨标签页通知晚一瞬,稍后由权威任务列表确认并挂载补流。 + setTimeout(function () { + if (typeof loadActiveTasks === 'function') loadActiveTasks(); + }, 180); + }); +} + +initChatTaskSyncChannel(); +window.notifyConversationTaskStarted = notifyConversationTaskStarted; + +const hitlPendingInterruptTracker = { + pendingById: new Map(), + ready: false, + update(items = []) { + this.pendingById.clear(); + items.forEach(item => this.add(item)); + this.ready = true; + }, + replaceConversation(conversationId, items = []) { + const id = String(conversationId || '').trim(); + if (id) { + this.pendingById.forEach((item, interruptId) => { + if (String(item && item.conversationId || '').trim() === id) { + this.pendingById.delete(interruptId); + } + }); + } + items.forEach(item => this.add(item)); + this.ready = true; + }, + add(item) { + const interruptId = String(item && (item.interruptId || item.id) || '').trim(); + if (!interruptId) return; + this.pendingById.set(interruptId, item); + }, + remove(interruptId) { + this.pendingById.delete(String(interruptId || '').trim()); + }, + has(interruptId) { + return !!interruptId && this.pendingById.has(String(interruptId)); } }; @@ -1041,6 +1171,102 @@ function isConversationTaskRunning(conversationId) { return conversationExecutionTracker.isRunning(conversationId); } +function setHitlApprovalInterruptedVisualState(panel, interrupted) { + if (!panel || panel.classList.contains('hitl-inline-done')) return; + const eyebrow = panel.querySelector('.hitl-approval-eyebrow'); + const countdown = panel.querySelector('.hitl-approval-countdown'); + if (interrupted) { + stopHitlApprovalCountdown(panel); + panel.classList.add('hitl-approval-interrupted'); + if (eyebrow && !Object.prototype.hasOwnProperty.call(eyebrow.dataset, 'taskAvailableText')) { + eyebrow.dataset.taskAvailableText = eyebrow.textContent || ''; + eyebrow.textContent = hitlApprovalTranslate('hitl.taskInterrupted', '任务已中断'); + } + if (countdown && !Object.prototype.hasOwnProperty.call(countdown.dataset, 'taskAvailableHtml')) { + countdown.dataset.taskAvailableHtml = countdown.innerHTML; + countdown.dataset.taskAvailableClass = countdown.className; + countdown.dataset.taskAvailableExpiresAt = countdown.dataset.hitlExpiresAt || ''; + countdown.dataset.taskAvailableTimeout = countdown.dataset.hitlTimeout || ''; + countdown.removeAttribute('data-hitl-expires-at'); + countdown.removeAttribute('data-hitl-timeout'); + countdown.className = 'hitl-approval-countdown hitl-approval-countdown--interrupted'; + countdown.innerHTML = '
' + + '' + + '' + escapeHtml(hitlApprovalTranslate('hitl.interruptedApprovalCancelled', '任务已中断,审批已取消')) + '' + + '
'; + } + return; + } + if (!panel.classList.contains('hitl-approval-interrupted')) return; + panel.classList.remove('hitl-approval-interrupted'); + if (eyebrow && Object.prototype.hasOwnProperty.call(eyebrow.dataset, 'taskAvailableText')) { + eyebrow.textContent = eyebrow.dataset.taskAvailableText; + delete eyebrow.dataset.taskAvailableText; + } + if (countdown && Object.prototype.hasOwnProperty.call(countdown.dataset, 'taskAvailableHtml')) { + countdown.className = countdown.dataset.taskAvailableClass || 'hitl-approval-countdown'; + countdown.innerHTML = countdown.dataset.taskAvailableHtml; + if (countdown.dataset.taskAvailableExpiresAt) { + countdown.dataset.hitlExpiresAt = countdown.dataset.taskAvailableExpiresAt; + } + if (countdown.dataset.taskAvailableTimeout) { + countdown.dataset.hitlTimeout = countdown.dataset.taskAvailableTimeout; + } + delete countdown.dataset.taskAvailableClass; + delete countdown.dataset.taskAvailableHtml; + delete countdown.dataset.taskAvailableExpiresAt; + delete countdown.dataset.taskAvailableTimeout; + if (panel.__hitlApprovalCountdownData) { + bindHitlApprovalCountdown(panel, panel.__hitlApprovalCountdownData); + } + } +} + +function setHitlApprovalTaskAvailability(panel, conversationId) { + if (!panel) return; + const id = String(conversationId || panel.dataset.conversationId || '').trim(); + if (id) panel.dataset.conversationId = id; + const interruptId = String(panel.dataset.hitlInterruptId || '').trim(); + const taskClosed = !!id && conversationExecutionTracker.ready && !conversationExecutionTracker.isRunning(id); + // 同一对话可能在服务重启后又启动了新任务,不能因此重新激活旧审批。 + // 具体审批 ID 已不在 pending 列表时,倒计时和按钮必须保持关闭。 + const interruptClosed = !!interruptId && hitlPendingInterruptTracker.ready && + !hitlPendingInterruptTracker.has(interruptId); + const approvalClosed = taskClosed || interruptClosed; + const buttons = panel.querySelectorAll( + '.hitl-inline-approve, .hitl-inline-reject, .workflow-hitl-inline-approve, .workflow-hitl-inline-reject' + ); + const status = panel.querySelector('.hitl-inline-status, .workflow-hitl-inline-status'); + panel.classList.toggle('hitl-approval-task-closed', approvalClosed); + setHitlApprovalInterruptedVisualState(panel, approvalClosed); + buttons.forEach(function (button) { + if (approvalClosed) { + if (!button.disabled) button.dataset.disabledByClosedTask = '1'; + button.disabled = true; + } else if (button.dataset.disabledByClosedTask === '1') { + button.disabled = false; + delete button.dataset.disabledByClosedTask; + } + }); + if (!status) return; + if (approvalClosed) { + if (!Object.prototype.hasOwnProperty.call(status.dataset, 'taskAvailableText')) { + status.dataset.taskAvailableText = status.textContent || ''; + } + status.textContent = hitlApprovalTranslate('hitl.taskClosedApprovalUnavailable', '任务已结束,审批不可用'); + } else if (Object.prototype.hasOwnProperty.call(status.dataset, 'taskAvailableText')) { + status.textContent = status.dataset.taskAvailableText; + delete status.dataset.taskAvailableText; + } +} + +function syncHitlApprovalTaskAvailability() { + document.querySelectorAll('.hitl-inline-approval[data-conversation-id], .chat-hitl-approval-dock[data-conversation-id]') + .forEach(function (panel) { + setHitlApprovalTaskAvailability(panel, panel.dataset.conversationId); + }); +} + /** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */ function findProgressIdByConversationId(conversationId) { if (!conversationId) { @@ -1087,6 +1313,7 @@ function markProgressCancelling(progressId) { } function finalizeProgressTask(progressId, finalLabel) { + stopLiveProgressLatestFollow(progressId); const stopBtn = document.getElementById(`${progressId}-stop-btn`); if (stopBtn) { stopBtn.disabled = true; @@ -1258,14 +1485,61 @@ async function performHardCancelProgressTask(progressId) { } } +const progressElapsedTimerById = new Map(); + +function progressElapsedText(progressId) { + const el = document.getElementById(progressId); + const startedAt = el && el.dataset ? Number(el.dataset.turnStartedAtMs) : NaN; + const duration = typeof window.formatAssistantTurnDuration === 'function' + ? window.formatAssistantTurnDuration(Number.isFinite(startedAt) ? Date.now() - startedAt : 0) + : Math.max(0, Math.floor((Date.now() - (Number.isFinite(startedAt) ? startedAt : Date.now())) / 1000)) + ' 秒'; + return typeof window.t === 'function' + ? window.t('chat.turnElapsedRunning', { duration: duration }) + : '已处理 ' + duration; +} + +function syncProgressElapsedSummary(progressId) { + const el = document.getElementById(progressId); + if (!el) return; + const title = el.querySelector('.progress-title'); + if (title) title.textContent = progressElapsedText(progressId); + const timeline = document.getElementById(progressId + '-timeline'); + const summary = el.querySelector('.progress-summary-toggle'); + if (summary) { + const expanded = !!(timeline && timeline.classList.contains('expanded')); + summary.classList.toggle('is-expanded', expanded); + summary.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + } +} + +function stopProgressElapsedClock(progressId) { + const timer = progressElapsedTimerById.get(progressId); + if (timer) clearInterval(timer); + progressElapsedTimerById.delete(progressId); +} + +function startProgressElapsedClock(progressId) { + stopProgressElapsedClock(progressId); + syncProgressElapsedSummary(progressId); + progressElapsedTimerById.set(progressId, setInterval(function () { + if (!document.getElementById(progressId)) { + stopProgressElapsedClock(progressId); + return; + } + syncProgressElapsedSummary(progressId); + }, 1000)); +} + function addProgressMessage() { const messagesDiv = document.getElementById('chat-messages'); const messageDiv = document.createElement('div'); messageCounter++; const id = 'progress-' + Date.now() + '-' + messageCounter; messageDiv.id = id; - messageDiv.className = 'message system progress-message'; - + messageDiv.className = 'message assistant progress-message'; + + messagesDiv.querySelector('.chat-welcome-empty-state')?.remove(); + const contentWrapper = document.createElement('div'); contentWrapper.className = 'message-content'; @@ -1276,12 +1550,19 @@ function addProgressMessage() { const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情'; bubble.innerHTML = `
- 🔍 ${progressTitleText} +
+
${progressTitleText}