using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using AxCopilot.Models; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // 스트리밍 중 LINQ 재실행 방지용 캐시 private List? _cachedVisibleMessages; private int _cachedVisibleMessagesSourceCount = -1; private List? _cachedVisibleEvents; private int _cachedVisibleEventsSourceCount = -1; private bool _cachedVisibleEventsShowHistory; private void InvalidateTimelineCache() { _cachedVisibleMessages = null; _cachedVisibleMessagesSourceCount = -1; _cachedVisibleEvents = null; _cachedVisibleEventsSourceCount = -1; } private List GetVisibleTimelineMessages(ChatConversation? conversation) { var sourceCount = conversation?.Messages?.Count ?? 0; if (_cachedVisibleMessages != null && sourceCount == _cachedVisibleMessagesSourceCount) return _cachedVisibleMessages; var result = conversation?.Messages?.Where(msg => { if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase)) return false; if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(msg.Content)) return false; // 에이전트 루프 내부 메시지 (도구 호출 블록 / 도구 결과) — 채팅 타임라인에 표시하지 않음 var content = msg.Content; if (content != null) { if (msg.Role == "assistant" && content.StartsWith("{\"_tool_use_blocks\"")) return false; if (msg.Role == "user" && content.StartsWith("{\"type\":\"tool_result\"")) return false; } return true; }).ToList() ?? new List(); _cachedVisibleMessages = result; _cachedVisibleMessagesSourceCount = sourceCount; return result; } private List GetVisibleTimelineEvents(ChatConversation? conversation) { var sourceCount = conversation?.ExecutionEvents?.Count ?? 0; var showHistory = conversation?.ShowExecutionHistory ?? true; if (_cachedVisibleEvents != null && sourceCount == _cachedVisibleEventsSourceCount && showHistory == _cachedVisibleEventsShowHistory) return _cachedVisibleEvents; List result; if (showHistory) { result = conversation?.ExecutionEvents?.ToList() ?? new List(); } else { result = (conversation?.ExecutionEvents ?? Enumerable.Empty()) .Where(ShouldShowCollapsedProgressEvent).ToList(); } _cachedVisibleEvents = result; _cachedVisibleEventsSourceCount = sourceCount; _cachedVisibleEventsShowHistory = showHistory; return result; } private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent) { var restoredEvent = ToAgentEvent(executionEvent); // Claude 스타일: SessionStart / UserPromptSubmit 운영 이벤트는 항상 숨김 if (restoredEvent.Type == AgentEventType.SessionStart || restoredEvent.Type == AgentEventType.UserPromptSubmit) return false; if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error) return true; if (restoredEvent.Type == AgentEventType.Thinking) { if (string.Equals(restoredEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) || string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) return true; if (!string.IsNullOrWhiteSpace(restoredEvent.Summary) && !AgentProgressSummarySanitizer.IsLowSignalStatusSummary( restoredEvent.Summary, restoredEvent.ToolName)) return true; } // 문서 생성 ToolResult 성공 시 항상 표시 (미리보기 카드용) if (restoredEvent.Type == AgentEventType.ToolResult && restoredEvent.Success && IsDocumentCreationTool(restoredEvent.ToolName)) return true; return IsProcessFeedEvent(restoredEvent); } private static bool IsDocumentCreationTool(string? toolName) => toolName is "html_create" or "docx_create" or "excel_create" or "xlsx_create" or "csv_create" or "markdown_create" or "md_create" or "script_create" or "pptx_create"; private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions( IReadOnlyCollection visibleMessages, IReadOnlyCollection visibleEvents) { var timeline = new List<(string Key, DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count); foreach (var msg in visibleMessages) { var capturedMsg = msg; var cacheKey = $"m_{msg.MsgId}"; timeline.Add((cacheKey, msg.Timestamp, 0, () => { AddDeferredTranscriptElement( cacheKey, () => { if (_elementCache.TryGetValue(cacheKey, out var cached)) return cached; var element = CreateMessageBubbleElement( capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg); _elementCache[cacheKey] = element; return element; }); })); } // 현재 실행 중인 run의 process feed 이벤트를 통합 카드로 대체 (히스토리 접힘 모드) var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true; var activeRunId = _isStreaming ? (GetCurrentAgentRunState(_activeTab)?.RunId ?? "") : ""; var eventIndex = 0; string? prevToolCallName = null; int consecutiveToolCallCount = 0; // 과거 대화(비-스트리밍) 히스토리: 연속 process feed 이벤트를 접힌 요약 그룹으로 묶기 var pendingProcessFeedGroup = new List(); DateTime pendingGroupTimestamp = default; void FlushProcessFeedGroup() { if (pendingProcessFeedGroup.Count == 0) return; var capturedGroup = pendingProcessFeedGroup.ToList(); var ts = pendingGroupTimestamp; var groupKey = $"eg_{ts.Ticks}_{eventIndex++}"; timeline.Add((groupKey, ts, 1, () => AddCollapsedProcessFeedGroup(capturedGroup))); pendingProcessFeedGroup.Clear(); } foreach (var executionEvent in visibleEvents) { // 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시 if (!showFullHistory && _isStreaming && !string.IsNullOrEmpty(activeRunId) && string.Equals(executionEvent.RunId, activeRunId, StringComparison.Ordinal)) { var restoredCheck = ToAgentEvent(executionEvent); if (IsProcessFeedEvent(restoredCheck)) continue; // 통합 카드로 대체 — 개별 pill 스킵 } var restoredEvent = ToAgentEvent(executionEvent); // 접힌 모드: 연속 동일 ToolCall 병합 (예: document_read 3회 → 1개 pill) if (!showFullHistory && restoredEvent.Type == AgentEventType.ToolCall && !string.IsNullOrWhiteSpace(restoredEvent.ToolName)) { if (string.Equals(prevToolCallName, restoredEvent.ToolName, StringComparison.OrdinalIgnoreCase)) { consecutiveToolCallCount++; continue; // 연속 중복 스킵 } prevToolCallName = restoredEvent.ToolName; consecutiveToolCallCount = 1; } else { prevToolCallName = null; consecutiveToolCallCount = 0; } // 과거 대화(비-스트리밍)에서 process feed 이벤트를 그룹으로 묶기 if (!_isStreaming && IsProcessFeedEvent(restoredEvent)) { pendingProcessFeedGroup.Add(restoredEvent); pendingGroupTimestamp = executionEvent.Timestamp; continue; } // 비-process feed 이벤트가 오면 이전 그룹 플러시 FlushProcessFeedGroup(); var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}"; timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent))); } // 마지막 그룹 플러시 FlushProcessFeedGroup(); // 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체) var currentRunProgressSteps = GetRunProgressSteps(_activeTab); if (!showFullHistory && _isStreaming && currentRunProgressSteps.Count > 0) { var capturedSteps = currentRunProgressSteps.ToList(); var cardTimestamp = capturedSteps[^1].Timestamp; // 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용 timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps))); } // 라이브 프로그레스 힌트는 트랜스크립트에 표시하지 않음 // 입력창 위의 PulseDotBar로만 상태를 표시 (Claude Desktop 스타일) // 이전에는 "처리 중..." / "작업을 준비하는 중입니다..." 등이 // 트랜스크립트에 불필요하게 표시되어 사용자 경험을 저해함 // 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피 var needsSort = false; for (int i = 1; i < timeline.Count; i++) { var cmp = timeline[i].Timestamp.CompareTo(timeline[i - 1].Timestamp); if (cmp < 0 || (cmp == 0 && timeline[i].Order < timeline[i - 1].Order)) { needsSort = true; break; } } if (needsSort) timeline.Sort((a, b) => { var cmp = a.Timestamp.CompareTo(b.Timestamp); return cmp != 0 ? cmp : a.Order.CompareTo(b.Order); }); return timeline; } private Border CreateTimelineLoadMoreCard(int hiddenCount) { var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155"); var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"); var loadMoreBtn = new Button { Background = Brushes.Transparent, BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(7, 3, 7, 3), Cursor = System.Windows.Input.Cursors.Hand, Foreground = primaryText, HorizontalAlignment = HorizontalAlignment.Center, }; loadMoreBtn.Template = BuildMinimalIconButtonTemplate(); loadMoreBtn.Content = new StackPanel { Orientation = Orientation.Horizontal, Children = { new TextBlock { Text = "\uE70D", FontFamily = s_segoeIconFont, FontSize = 8, Foreground = secondaryText, Margin = new Thickness(0, 0, 4, 0), VerticalAlignment = VerticalAlignment.Center, }, new TextBlock { Text = $"이전 대화 {hiddenCount:N0}개", FontSize = 9.25, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, } } }; loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg; loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent; loadMoreBtn.Click += (_, _) => { _timelineRenderLimit += TimelineRenderPageSize; RenderMessages(preserveViewport: true); }; return new Border { CornerRadius = new CornerRadius(10), Margin = new Thickness(0, 2, 0, 8), Padding = new Thickness(0), Background = Brushes.Transparent, BorderBrush = Brushes.Transparent, BorderThickness = new Thickness(0), HorizontalAlignment = HorizontalAlignment.Center, Child = loadMoreBtn, }; } private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent) { var parsedType = Enum.TryParse(executionEvent.Type, out var eventType) ? eventType : AgentEventType.Thinking; return new AgentEvent { Timestamp = executionEvent.Timestamp, RunId = executionEvent.RunId, Type = parsedType, ToolName = executionEvent.ToolName, Summary = executionEvent.Summary, FilePath = executionEvent.FilePath, Success = executionEvent.Success, StepCurrent = executionEvent.StepCurrent, StepTotal = executionEvent.StepTotal, Steps = executionEvent.Steps, ElapsedMs = executionEvent.ElapsedMs, InputTokens = executionEvent.InputTokens, OutputTokens = executionEvent.OutputTokens, }; } private static bool IsCompactionMetaMessage(ChatMessage? message) { var kind = message?.MetaKind ?? ""; return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase) || kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase) || kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase); } private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) { var expressionLevel = GetAgentUiExpressionLevel(); var icon = "\uE9CE"; var title = message.MetaKind switch { "session_memory_compaction" => "세션 메모리 압축", "collapsed_boundary" => "압축 경계 병합", _ => "컨텍스트 정리", }; var wrapper = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), Padding = new Thickness(12, 10, 12, 10), Margin = new Thickness(10, 4, 150, 4), MaxWidth = GetMessageMaxWidth(), HorizontalAlignment = HorizontalAlignment.Left, }; var stack = new StackPanel(); var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) }; header.Children.Add(new TextBlock { Text = icon, FontFamily = s_segoeIconFont, FontSize = 11, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, }); header.Children.Add(new TextBlock { Text = title, FontSize = 11, FontWeight = FontWeights.SemiBold, Foreground = primaryText, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }); stack.Children.Add(header); var summary = message.MetaKind switch { "session_memory_compaction" => "이전 기록을 세션 메모로 정리했습니다.", "collapsed_boundary" => "이전 압축 경계를 합쳤습니다.", _ => "오래된 실행 기록을 줄였습니다.", }; var detailBits = new List(); if (message.AttachedFiles?.Count > 0 && expressionLevel != "simple") detailBits.Add($"파일 {message.AttachedFiles.Count}개"); var compactDetail = expressionLevel switch { "simple" => summary, _ when detailBits.Count > 0 => $"{summary} · {string.Join(", ", detailBits)}", _ => summary }; stack.Children.Add(new TextBlock { Text = compactDetail, FontSize = expressionLevel == "simple" ? 10 : 10.5, Foreground = secondaryText, TextWrapping = TextWrapping.Wrap, }); if (expressionLevel == "rich" && !string.IsNullOrWhiteSpace(message.Content)) { var preview = message.Content.Trim(); if (preview.Length > 120) preview = preview[..120] + "..."; stack.Children.Add(new TextBlock { Text = preview, FontSize = 10, Foreground = secondaryText, Opacity = 0.88, Margin = new Thickness(0, 6, 0, 0), TextWrapping = TextWrapping.Wrap, }); } wrapper.Child = stack; return wrapper; } }