- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 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)
204 lines
9.2 KiB
C#
204 lines
9.2 KiB
C#
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Windows.Threading;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
// ─── 렌더링 쓰로틀: 스트리밍 중 최소 간격 보장 ───────────────────────
|
|
private long _lastRenderTicks;
|
|
private const long MinStreamingRenderIntervalMs = 1500; // 스트리밍 중 최소 1.5초 간격
|
|
private const long MinIdleRenderIntervalMs = 300; // 비스트리밍(유휴) 시 최소 300ms 간격
|
|
|
|
private int GetActiveTimelineRenderLimit()
|
|
{
|
|
if (!_isStreaming)
|
|
return _timelineRenderLimit;
|
|
|
|
var streamingLimit = IsLightweightLiveProgressMode()
|
|
? TimelineLightweightStreamingRenderLimit
|
|
: TimelineStreamingRenderLimit;
|
|
return Math.Min(_timelineRenderLimit, streamingLimit);
|
|
}
|
|
|
|
private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
|
{
|
|
// B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지
|
|
if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible)
|
|
return;
|
|
|
|
var now = Environment.TickCount64;
|
|
|
|
// B-5: 스트리밍 중 쓰로틀 — preserveViewport=true (타이머 기반) 호출만 제한
|
|
// preserveViewport=false는 사용자 메시지 전송 등 중요 렌더이므로 항상 허용
|
|
if (_isStreaming && preserveViewport)
|
|
{
|
|
if (now - _lastRenderTicks < MinStreamingRenderIntervalMs)
|
|
return;
|
|
}
|
|
|
|
// B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인)
|
|
// 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시
|
|
if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs)
|
|
{
|
|
ChatConversation? quickConv;
|
|
lock (_convLock) quickConv = _currentConversation;
|
|
var quickMsgCount = quickConv?.Messages?.Count ?? 0;
|
|
var quickEvtCount = quickConv?.ExecutionEvents?.Count ?? 0;
|
|
// 대화 내용이 마지막 렌더와 같으면 스킵 (빈 대화 반복 렌더 차단)
|
|
if (quickMsgCount == _lastRenderedMessageCount
|
|
&& quickEvtCount == _lastRenderedEventCount
|
|
&& string.Equals(_lastRenderedConversationId, quickConv?.Id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
// B-7b: 빈 대화 간 convId 플래핑 방지 — 둘 다 메시지 0개면
|
|
// convId가 달라도 빈 화면 렌더를 반복할 이유 없음 (SwitchToTabConversation 스팸 차단)
|
|
// 단, preserveViewport=false(탭 전환 등 명시적 렌더)는 차단하지 않음
|
|
// — 탭 전환 시 EmptyState/마스코트 표시에 필요
|
|
if (preserveViewport
|
|
&& quickMsgCount == 0 && quickEvtCount == 0
|
|
&& _lastRenderedMessageCount == 0 && _lastRenderedEventCount == 0)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
var renderStopwatch = Stopwatch.StartNew();
|
|
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
|
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
|
|
|
ChatConversation? conv;
|
|
lock (_convLock) conv = _currentConversation;
|
|
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
|
|
|
var visibleMessages = GetVisibleTimelineMessages(conv);
|
|
var visibleEvents = GetVisibleTimelineEvents(conv);
|
|
|
|
// 진단 로그: 렌더링 호출 시점의 상태 추적
|
|
Services.LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " +
|
|
$"convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}, " +
|
|
$"rawMsgCount={conv?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " +
|
|
$"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " +
|
|
$"transcriptElements={GetTranscriptElementCount()}");
|
|
|
|
if (_isStreaming && preserveViewport
|
|
&& visibleMessages.Count == _lastRenderedMessageCount
|
|
&& visibleEvents.Count == _lastRenderedEventCount
|
|
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
|
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
|
|
return;
|
|
|
|
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
|
{
|
|
// 스트리밍 중이거나 대화에 원본 메시지가 있으면 EmptyState를 표시하지 않음
|
|
// (GetVisibleTimelineMessages의 필터링으로 visibleMessages가 0이 되어도 원본은 존재)
|
|
bool hasRawMessages = (conv?.Messages?.Count ?? 0) > 0;
|
|
if (!_isStreaming && !hasRawMessages)
|
|
{
|
|
ClearTranscriptElements();
|
|
_runBannerAnchors.Clear();
|
|
_lastRenderedTimelineKeys.Clear();
|
|
_lastRenderedMessageCount = 0;
|
|
_lastRenderedEventCount = 0;
|
|
EmptyState.Visibility = System.Windows.Visibility.Visible;
|
|
StartMascotAnimation();
|
|
}
|
|
else
|
|
{
|
|
// 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김
|
|
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
|
StopMascotAnimation();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_lastRenderedConversationId = conv.Id;
|
|
_timelineRenderLimit = TimelineRenderPageSize;
|
|
_elementCache.Clear();
|
|
_lastRenderedTimelineKeys.Clear();
|
|
_lastRenderedMessageCount = 0;
|
|
_lastRenderedEventCount = 0;
|
|
InvalidateTimelineCache();
|
|
}
|
|
|
|
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
|
StopMascotAnimation();
|
|
|
|
// V2 렌더링 분기 — 설정 토글로 Claude Code 스타일 상세 이력 UI 활성화
|
|
if (_settings.Settings.Llm.EnableNewChatRendering)
|
|
{
|
|
RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport,
|
|
previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents);
|
|
|
|
Services.LogService.Info($"[Render] plan: items={renderPlan.VisibleTimeline.Count}, hidden={renderPlan.HiddenCount}, " +
|
|
$"canIncremental={renderPlan.CanIncremental}, keys={renderPlan.NewKeys.Count}");
|
|
|
|
// B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → Diff(Virtual DOM) → 전체 재빌드
|
|
if (!TryApplyStreamingAppendRender(renderPlan)
|
|
&& !TryApplyIncrementalTranscriptRender(renderPlan)
|
|
&& !TryApplyDiffRender(renderPlan))
|
|
ApplyFullTranscriptRender(renderPlan);
|
|
PruneTranscriptElementCache(renderPlan.NewKeys);
|
|
|
|
_lastRenderedMessageCount = visibleMessages.Count;
|
|
_lastRenderedEventCount = visibleEvents.Count;
|
|
_lastRenderedShowHistory = renderPlan.ShowHistory;
|
|
}
|
|
catch (Exception renderEx)
|
|
{
|
|
Services.LogService.Error($"[Render] 렌더링 파이프라인 예외: {renderEx.GetType().Name}: {renderEx.Message}\n{renderEx.StackTrace}");
|
|
}
|
|
|
|
_lastRenderTicks = Environment.TickCount64; // 쓰로틀 타임스탬프 갱신
|
|
renderStopwatch.Stop();
|
|
// B-6: 스트리밍 중 로깅 빈도 축소 — 100ms 미만 렌더는 기록하지 않음 (UI 부하 감소)
|
|
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
|
|
{
|
|
AgentPerformanceLogService.LogMetric(
|
|
"transcript",
|
|
"render_messages",
|
|
conv.Id,
|
|
_activeTab ?? "",
|
|
renderStopwatch.ElapsedMilliseconds,
|
|
new
|
|
{
|
|
preserveViewport,
|
|
streaming = _isStreaming,
|
|
lightweight = IsLightweightLiveProgressMode(),
|
|
visibleMessages = visibleMessages.Count,
|
|
visibleEvents = visibleEvents.Count,
|
|
transcriptElements = GetTranscriptElementCount(),
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|