Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.TranscriptRenderExecution.cs
lacvet fb0bea41f7 AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리
- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:02:14 +09:00

237 lines
9.3 KiB
C#

using System.Collections.Generic;
namespace AxCopilot.Views;
public partial class ChatWindow
{
/// <summary>
/// B-3: 스트리밍 전용 append-only 렌더 경로.
/// prefix 비교를 완전히 우회하고, stable 키의 부분집합 관계만 확인하여
/// 새 항목만 추가하는 빠른 경로. B-1/B-2로 인크리멘탈이 대부분 성공하지만,
/// 스트리밍 중 키 순서가 변경되는 극단적 경우에도 전체 재빌드를 방지합니다.
/// </summary>
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<string>(StringComparer.Ordinal);
foreach (var key in _lastRenderedTimelineKeys)
{
if (!key.StartsWith("_live_", StringComparison.Ordinal))
previousStable.Add(key);
}
var newStableSet = new HashSet<string>(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;
}
}
/// <summary>
/// React Virtual DOM reconciliation 방식의 diff 렌더.
/// Incremental(prefix-match)이 실패해도, 키 기반 diff로 삭제/추가만 처리하여
/// 전체 재빌드(Full Render)를 회피합니다.
/// StreamingAppend → Incremental → DiffRender → FullRender 순으로 호출됩니다.
/// </summary>
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<string, int>(oldKeys.Count, StringComparer.Ordinal);
for (var i = 0; i < oldKeys.Count; i++)
oldKeyIndex[oldKeys[i]] = i;
var newKeySet = new HashSet<string>(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;
}
}