using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // ─── V2 렌더 상태 ─────────────────────────────────────────────────── private string? _v2LastRenderedConversationId; private int _v2LastRenderedMessageCount; private int _v2LastRenderedEventCount; private readonly List _v2LastRenderedKeys = new(); // V2 라이브 프로그레스 상태 private StackPanel? _v2LiveContainer; private readonly Dictionary _v2LiveToolCards = new(); private string? _v2LastLiveToolCallId; private void RenderMessagesV2( ChatConversation conv, IReadOnlyList visibleMessages, IReadOnlyList visibleEvents, bool preserveViewport, double previousScrollableHeight, double previousVerticalOffset, Stopwatch renderStopwatch, string? caller) { try { // 대화 전환 감지 → 캐시 초기화 if (!string.Equals(_v2LastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase)) { _v2LastRenderedConversationId = conv.Id; _v2LastRenderedKeys.Clear(); _v2LastRenderedMessageCount = 0; _v2LastRenderedEventCount = 0; _elementCache.Clear(); } // ★ 패널이 비어있는데 V2 캐시가 남아있는 경우 강제 리셋 // 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만 // 빈 대화 탭을 거치면 V2가 호출되지 않아 캐시가 잔류하는 버그 방지 if (GetTranscriptElementCount() == 0 && _v2LastRenderedKeys.Count > 0) { LogService.Info($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild"); _v2LastRenderedKeys.Clear(); _v2LastRenderedMessageCount = 0; _v2LastRenderedEventCount = 0; } // 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합) var timeline = BuildV2Timeline(visibleMessages, visibleEvents); LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}"); // 새 키 목록 생성 var newKeys = new List(timeline.Count); foreach (var item in timeline) newKeys.Add(item.Key); // 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더 // ★ 패널에 실제 엘리먼트가 있어야 인크리멘탈 가능 — // 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만 // V2 캐시(_v2LastRenderedKeys)는 유지되어 "이미 렌더됨"으로 오판하는 버그 방지 var actualElementCount = GetTranscriptElementCount(); var canIncremental = _v2LastRenderedKeys.Count > 0 && actualElementCount > 0 && newKeys.Count >= _v2LastRenderedKeys.Count && KeysArePrefixMatch(_v2LastRenderedKeys, newKeys); LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}"); if (canIncremental) { // 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가) if (_v2LiveContainer != null && ContainsTranscriptElement(_v2LiveContainer)) RemoveTranscriptElement(_v2LiveContainer); for (int i = _v2LastRenderedKeys.Count; i < timeline.Count; i++) { var item = timeline[i]; AddDeferredTranscriptElement(item.Key, () => { if (_elementCache.TryGetValue(item.Key, out var cached)) return cached; var element = item.CreateElement(); _elementCache[item.Key] = element; return element; }); } // 라이브 컨테이너 재삽입 if (_v2LiveContainer != null && _isStreaming) AddTranscriptElement(_v2LiveContainer); } else { // 전체 재빌드 ClearTranscriptElements(); foreach (var item in timeline) { var capturedItem = item; AddDeferredTranscriptElement(capturedItem.Key, () => { if (_elementCache.TryGetValue(capturedItem.Key, out var cached)) return cached; var element = capturedItem.CreateElement(); _elementCache[capturedItem.Key] = element; return element; }); } // 라이브 컨테이너 재삽입 if (_v2LiveContainer != null && _isStreaming) AddTranscriptElement(_v2LiveContainer); } _v2LastRenderedKeys.Clear(); _v2LastRenderedKeys.AddRange(newKeys); _v2LastRenderedMessageCount = visibleMessages.Count; _v2LastRenderedEventCount = visibleEvents.Count; LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}"); } catch (Exception ex) { LogService.Error($"[V2Render] 렌더링 예외: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); } _lastRenderTicks = Environment.TickCount64; _lastRenderedMessageCount = visibleMessages.Count; _lastRenderedEventCount = visibleEvents.Count; _lastRenderedShowHistory = conv?.ShowExecutionHistory ?? true; renderStopwatch.Stop(); if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24)) { AgentPerformanceLogService.LogMetric( "transcript", "render_messages_v2", conv?.Id ?? "", _activeTab ?? "", renderStopwatch.ElapsedMilliseconds, new { preserveViewport, streaming = _isStreaming, visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count }); } if (!preserveViewport) { _ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background); return; } _ = Dispatcher.InvokeAsync(() => { if (_transcriptScrollViewer == null) return; var newScrollableHeight = GetTranscriptScrollableHeight(); var delta = newScrollableHeight - previousScrollableHeight; var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta)); ScrollTranscriptToVerticalOffset(targetOffset); }, DispatcherPriority.Background); } private static bool KeysArePrefixMatch(List oldKeys, List newKeys) { for (int i = 0; i < oldKeys.Count; i++) { if (!string.Equals(oldKeys[i], newKeys[i], StringComparison.Ordinal)) return false; } return true; } private sealed record V2TimelineItem(string Key, DateTime Timestamp, Func CreateElement); private List BuildV2Timeline( IReadOnlyList visibleMessages, IReadOnlyList visibleEvents) { var timeline = new List(visibleMessages.Count + visibleEvents.Count); // 1. 메시지 추가 foreach (var msg in visibleMessages) { var capturedMsg = msg; var key = $"v2m_{msg.MsgId}"; timeline.Add(new V2TimelineItem(key, msg.Timestamp, () => CreateV2MessageElement(capturedMsg))); } // 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합 // 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외 var eventIndex = 0; var events = visibleEvents.ToList(); var liveCardCutoff = (_isStreaming && _v2LiveContainer != null) ? _v2LiveStartTime : DateTime.MaxValue; for (int i = 0; i < events.Count; i++) { var executionEvent = events[i]; // 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵 if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff) continue; var agentEvent = ToAgentEvent(executionEvent); // SessionStart / UserPromptSubmit 숨김 if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit) continue; var key = $"v2e_{executionEvent.Timestamp.Ticks}_{eventIndex++}"; // ToolCall → 바로 다음 같은 도구의 ToolResult를 찾아 병합 if (agentEvent.Type == AgentEventType.ToolCall && i + 1 < events.Count) { var nextEvent = ToAgentEvent(events[i + 1]); if (nextEvent.Type == AgentEventType.ToolResult && string.Equals(nextEvent.ToolName, agentEvent.ToolName, StringComparison.OrdinalIgnoreCase)) { var capturedCall = agentEvent; var capturedResult = nextEvent; timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp, () => CreateV2ToolExecutionCard(capturedCall, capturedResult))); i++; // ToolResult 스킵 continue; } } var capturedEvent = agentEvent; timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp, () => CreateV2AgentEventElement(capturedEvent))); } // 시간순 정렬 var needsSort = false; for (int i = 1; i < timeline.Count; i++) { if (timeline[i].Timestamp < timeline[i - 1].Timestamp) { needsSort = true; break; } } if (needsSort) timeline.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp)); return timeline; } }