using System.Collections.Generic; namespace AxCopilot.Views; public partial class ChatWindow { /// /// B-3: 스트리밍 전용 append-only 렌더 경로. /// prefix 비교를 완전히 우회하고, stable 키의 부분집합 관계만 확인하여 /// 새 항목만 추가하는 빠른 경로. B-1/B-2로 인크리멘탈이 대부분 성공하지만, /// 스트리밍 중 키 순서가 변경되는 극단적 경우에도 전체 재빌드를 방지합니다. /// private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan) { if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0 || renderPlan.NewKeys.Count == 0) return false; // hiddenCount가 다르면 visible 범위 자체가 달라진 것 — append 불가 if (renderPlan.HiddenCount != _lastRenderedHiddenCount) return false; // 기존 stable 키가 새 키 집합의 부분집합인지 확인 (순서 무관) var previousStable = new HashSet(StringComparer.Ordinal); foreach (var key in _lastRenderedTimelineKeys) { if (!key.StartsWith("_live_", StringComparison.Ordinal)) previousStable.Add(key); } var newStableSet = new HashSet(StringComparer.Ordinal); foreach (var key in renderPlan.NewKeys) { if (!key.StartsWith("_live_", StringComparison.Ordinal)) newStableSet.Add(key); } if (!previousStable.IsSubsetOf(newStableSet)) return false; try { // 라이브 컨테이너 임시 분리 var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer); if (hadLiveContainer) RemoveTranscriptElement(_agentLiveContainer!); // 기존 live 항목 제거 (끝에서부터) for (var i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--) { if (!_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal)) break; if (GetTranscriptElementCount() > 0) RemoveTranscriptElementAt(GetTranscriptElementCount() - 1); } // 새로 추가된 항목만 렌더 (기존에 없던 키) foreach (var item in renderPlan.VisibleTimeline) { if (!previousStable.Contains(item.Key)) item.Render(); } // 라이브 컨테이너 재삽입 if (hadLiveContainer && _agentLiveContainer != null) AddTranscriptElement(_agentLiveContainer); _lastRenderedTimelineKeys = renderPlan.NewKeys; _lastRenderedHiddenCount = renderPlan.HiddenCount; return true; } catch (Exception ex) { Services.LogService.Warn($"스트리밍 append 렌더 실패, 전체 렌더로 전환: {ex.Message}"); _lastRenderedTimelineKeys.Clear(); return false; } } private bool TryApplyIncrementalTranscriptRender(TranscriptRenderPlan renderPlan) { if (!renderPlan.CanIncremental) return false; var prefixMatch = true; for (var i = 0; i < renderPlan.PreviousStableCount; i++) { if (i >= renderPlan.NewKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], renderPlan.NewKeys[i], StringComparison.Ordinal)) { prefixMatch = false; break; } } if (!prefixMatch) return false; try { // B-2: 라이브 컨테이너가 transcript 끝에 있으면 임시 분리 var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer); if (hadLiveContainer) RemoveTranscriptElement(_agentLiveContainer!); for (var removeIndex = 0; removeIndex < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; removeIndex++) RemoveTranscriptElementAt(GetTranscriptElementCount() - 1); for (var i = renderPlan.PreviousStableCount; i < renderPlan.VisibleTimeline.Count; i++) renderPlan.VisibleTimeline[i].Render(); // B-2: 라이브 컨테이너 재삽입 if (hadLiveContainer && _agentLiveContainer != null) AddTranscriptElement(_agentLiveContainer); _lastRenderedTimelineKeys = renderPlan.NewKeys; _lastRenderedHiddenCount = renderPlan.HiddenCount; return true; } catch (Exception ex) { Services.LogService.Warn($"증분 transcript 렌더 실패, 전체 렌더로 전환: {ex.Message}"); _lastRenderedTimelineKeys.Clear(); return false; } } /// /// React Virtual DOM reconciliation 방식의 diff 렌더. /// Incremental(prefix-match)이 실패해도, 키 기반 diff로 삭제/추가만 처리하여 /// 전체 재빌드(Full Render)를 회피합니다. /// StreamingAppend → Incremental → DiffRender → FullRender 순으로 호출됩니다. /// private bool TryApplyDiffRender(TranscriptRenderPlan renderPlan) { // 이전 렌더 기록이 없으면 diff 불가 if (_lastRenderedTimelineKeys.Count == 0 || renderPlan.NewKeys.Count == 0) return false; // hiddenCount가 다르면 visible 범위 자체가 달라진 것 — diff 신뢰 불가 if (renderPlan.HiddenCount != _lastRenderedHiddenCount) return false; var oldKeys = _lastRenderedTimelineKeys; var newKeys = renderPlan.NewKeys; // 변화가 없으면 빠른 경로 if (oldKeys.Count == newKeys.Count && oldKeys.SequenceEqual(newKeys, StringComparer.Ordinal)) { _lastRenderedTimelineKeys = renderPlan.NewKeys; return true; } try { // 1. 기존 키 → 인덱스 매핑 var oldKeyIndex = new Dictionary(oldKeys.Count, StringComparer.Ordinal); for (var i = 0; i < oldKeys.Count; i++) oldKeyIndex[oldKeys[i]] = i; var newKeySet = new HashSet(newKeys, StringComparer.Ordinal); // 2. 라이브 컨테이너 임시 분리 var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer); if (hadLiveContainer) RemoveTranscriptElement(_agentLiveContainer!); // "더보기" 카드가 있으면 오프셋 1 var loadMoreOffset = renderPlan.HiddenCount > 0 ? 1 : 0; // 3. 삭제할 항목 제거 (뒤에서부터 — 인덱스 안정성 유지) for (var i = oldKeys.Count - 1; i >= 0; i--) { if (!newKeySet.Contains(oldKeys[i])) { var elementIndex = i + loadMoreOffset; if (elementIndex < GetTranscriptElementCount()) RemoveTranscriptElementAt(elementIndex); } } // 4. 새 항목만 생성·삽입 — 이미 존재하는 키는 건너뜀 foreach (var item in renderPlan.VisibleTimeline) { if (!oldKeyIndex.ContainsKey(item.Key)) item.Render(); } // 5. 라이브 컨테이너 재삽입 if (hadLiveContainer && _agentLiveContainer != null) AddTranscriptElement(_agentLiveContainer); _lastRenderedTimelineKeys = renderPlan.NewKeys; _lastRenderedHiddenCount = renderPlan.HiddenCount; return true; } catch (Exception ex) { Services.LogService.Warn($"Diff 렌더 실패, 전체 렌더로 전환: {ex.Message}"); _lastRenderedTimelineKeys.Clear(); return false; } } private void ApplyFullTranscriptRender(TranscriptRenderPlan renderPlan) { // 스트리밍 중에는 ItemsSource 분리/재연결을 하지 않음 // — 전체 시각적 트리 파괴 + VirtualizingStackPanel 컨테이너 재생성이 UI 렉의 핵심 원인 // 비스트리밍 시에만 분리/재연결 (대량 초기 로드 시 레이아웃 패스 1회 축소 효과) var disconnectItemsSource = !_isStreaming; if (disconnectItemsSource) MessageList.ItemsSource = null; ClearTranscriptElements(); _runBannerAnchors.Clear(); if (renderPlan.HiddenCount > 0) AddTranscriptElement(CreateTimelineLoadMoreCard(renderPlan.HiddenCount)); foreach (var item in renderPlan.VisibleTimeline) item.Render(); if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer)) AddTranscriptElement(_agentLiveContainer); if (disconnectItemsSource) { // ItemsSource 재연결 — 단일 레이아웃 패스 MessageList.ItemsSource = _transcriptElements; // ItemsSource 변경 시 ScrollViewer가 재생성될 수 있으므로 훅 재연결 AttachTranscriptScrollChanged(); } _lastRenderedTimelineKeys = renderPlan.NewKeys; _lastRenderedHiddenCount = renderPlan.HiddenCount; } }