diff --git a/README.md b/README.md index 49ba440..4178b22 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,10 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` -구조 비교 참고 문서 -`docs/CLAUDE_CODE_AX_AGENT_COMPARISON.md` - -- 업데이트: 2026-04-09 10:20 (KST) -- `claude-code`와 AX Agent의 구조/루프/메모리/도구 실행 전략을 한 문서에서 대조할 수 있도록 `docs/CLAUDE_CODE_AX_AGENT_COMPARISON.md`를 추가했습니다. -- 문서는 `claude-code`의 에이전트 루프, Cowork/Code 결과 품질이 잘 나오는 이유, 성능에 영향을 주는 프롬프트 전략과 AX Agent의 현재 구조/강점/남은 차이를 함께 정리합니다. +- 업데이트: 2026-04-09 01:33 (KST) +- AX Agent transcript 호스트를 `ObservableCollection` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다. +- `StreamingToolExecutionCoordinator`를 `IToolExecutionCoordinator` 인터페이스 뒤로 숨겨 Cowork/Code tool streaming executor를 루프 구현에서 구조적으로 분리했습니다. 이후 executor 교체, 테스트 더블 주입, 모델별 executor 분기를 더 쉽게 할 수 있는 상태입니다. +- AX Agent 라이브 진행 카드 렌더 책임을 `ChatWindow.LiveProgressPresentation.cs`로 이동해 `ChatWindow.xaml.cs`의 오케스트레이션 밀도를 더 낮췄습니다. transcript/라이브 카드/루프 쪽 책임 분해가 한 단계 더 진행됐습니다. - 업데이트: 2026-04-07 09:19 (KST) - AX Agent 하단 상태바의 전체 토큰 집계가 유휴 전환 후 사라지지 않도록 conversation aggregate 복원 경로를 추가했습니다. 실행 중 누적 토큰이 0이어도 현재 대화 전체의 prompt/completion 합계를 다시 계산해 상태바에 유지합니다. diff --git a/dist/AxCopilot/AxCopilot.exe b/dist/AxCopilot/AxCopilot.exe index a1b1a68..0b92feb 100644 Binary files a/dist/AxCopilot/AxCopilot.exe and b/dist/AxCopilot/AxCopilot.exe differ diff --git a/dist/AxCopilot_Setup.exe b/dist/AxCopilot_Setup.exe index 72ecd86..6dfb653 100644 Binary files a/dist/AxCopilot_Setup.exe and b/dist/AxCopilot_Setup.exe differ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d84168f..f477240 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,7 @@ -- Document update: 2026-04-07 02:11 (KST) - Replaced the AX Agent internal-settings service API key input from PasswordBox to TextBox so Gemini/Claude keys can be entered reliably even while the overlay is resyncing service state. +- Document update: 2026-04-09 01:33 (KST) - Raised AX Agent transcript hosting from a raw ObservableCollection list to a TranscriptVisualItem wrapper model plus TranscriptVisualHost. MessageList now binds lightweight transcript items that materialize their actual UIElement content on demand, which moves the message surface closer to the claude-code style separation between data planning and visual realization. +- Document update: 2026-04-09 01:33 (KST) - Added IToolExecutionCoordinator and switched AgentLoopService away from a direct dependency on StreamingToolExecutionCoordinator. Tool streaming/prefetch recovery remains behaviorally the same for now, but the executor is now structurally replaceable and easier to isolate for model-specific orchestration work. +- Document update: 2026-04-09 01:33 (KST) - Split AX Agent live progress card rendering out of ChatWindow.xaml.cs into ChatWindow.LiveProgressPresentation.cs, continuing the claude-code parity cleanup where transcript host, render planning, render execution, and live-progress UI live in separate partials instead of a single main-window file. +- Document update: 2026-04-07 02:11 (KST) - Replaced the AX Agent internal-settings service API key input from PasswordBox to TextBox so Gemini/Claude keys can be entered reliably even while the overlay is resyncing service state. - Document update: 2026-04-07 02:03 (KST) - Fixed broken Korean strings in Cowork preset selection, composer guidance, and live progress/process-feed rendering. Rewrote ChatWindow.TopicPresetPresentation, normalized live progress copy, and cleaned up loop gate guidance strings that had become mojibake. - Document update: 2026-04-07 02:03 (KST) - Adjusted AX Agent progress visibility so Cowork/Code now shows an immediate live hint when a run starts and no longer clears that hint on every intermediate agent event. Neutralized cancellation wording to 작업이 중단되었습니다 for non-user interruption paths. @@ -5559,3 +5562,4 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 루프 finally 단계에서 `run_summary` 성능 로그를 남기도록 보강했다. - iteration 수, tool 호출 수, 토큰 사용량, post-compaction suppression 수치가 함께 기록돼 사내 모델에서 `느린데 왜 느린지`를 나중에 역추적할 수 있다. - Document update: 2026-04-09 10:20 (KST) - Added `docs/CLAUDE_CODE_AX_AGENT_COMPARISON.md` as a new baseline document for architecture and parity reviews. It summarizes `claude-code` structure, loop, transcript/tool orchestration, prompt-quality strategy, AX Agent structure and loop, and a current comparison between both implementations. + diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 57fdb23..4e143e4 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -26,7 +26,7 @@ public partial class AgentLoopService private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; - private readonly StreamingToolExecutionCoordinator _toolExecutionCoordinator; + private readonly IToolExecutionCoordinator _toolExecutionCoordinator; private readonly ConcurrentDictionary _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase); /// 에이전트 이벤트 스트림 (UI 바인딩용). diff --git a/src/AxCopilot/Services/Agent/IToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/IToolExecutionCoordinator.cs new file mode 100644 index 0000000..b393346 --- /dev/null +++ b/src/AxCopilot/Services/Agent/IToolExecutionCoordinator.cs @@ -0,0 +1,22 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +internal interface IToolExecutionCoordinator +{ + Task TryPrefetchReadOnlyToolAsync( + LlmService.ContentBlock block, + IReadOnlyCollection tools, + AgentContext context, + CancellationToken ct); + + Task> SendWithToolsWithRecoveryAsync( + List messages, + IReadOnlyCollection tools, + CancellationToken ct, + string phaseLabel, + AgentLoopService.RunState? runState = null, + bool forceToolCall = false, + Func>? prefetchToolCallAsync = null, + Func? onStreamEventAsync = null); +} diff --git a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs index 885ca9a..a5dd354 100644 --- a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs +++ b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs @@ -7,7 +7,7 @@ using AxCopilot.Services; namespace AxCopilot.Services.Agent; -internal sealed class StreamingToolExecutionCoordinator +internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordinator { private static readonly HashSet PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) { diff --git a/src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs b/src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs new file mode 100644 index 0000000..834915d --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs @@ -0,0 +1,208 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private void ShowAgentLiveCard(string runTab) + { + if (MessageList == null) return; + if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return; + + RemoveAgentLiveCard(animated: false); + + _agentLiveStartTime = DateTime.UtcNow; + _agentLiveSubItemTexts.Clear(); + _agentLiveCurrentCategory = null; + + var msgMaxWidth = GetMessageMaxWidth(); + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var container = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Center, + Width = msgMaxWidth, + MaxWidth = msgMaxWidth, + Margin = new Thickness(0, 4, 0, 6), + Opacity = IsLightweightLiveProgressMode(runTab) ? 1 : 0, + RenderTransform = IsLightweightLiveProgressMode(runTab) + ? Transform.Identity + : new TranslateTransform(0, 8), + }; + if (!IsLightweightLiveProgressMode(runTab)) + { + container.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(260))); + ((TranslateTransform)container.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(8, 0, TimeSpan.FromMilliseconds(280)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } + }); + } + + var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 3) }; + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var liveIcon = CreateMiniLauncherIcon(pixelSize: 4.0); + if (!IsLightweightLiveProgressMode(runTab)) + { + liveIcon.BeginAnimation( + UIElement.OpacityProperty, + new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(750)) + { + AutoReverse = true, + RepeatBehavior = RepeatBehavior.Forever, + EasingFunction = new SineEase() + }); + } + + Grid.SetColumn(liveIcon, 0); + headerGrid.Children.Add(liveIcon); + + var (agentName, _, _) = GetAgentIdentity(); + var nameTb = new TextBlock + { + Text = agentName, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(nameTb, 1); + headerGrid.Children.Add(nameTb); + + _agentLiveElapsedText = new TextBlock + { + Text = "", + FontSize = 10, + Foreground = secondaryText, + Opacity = 0.50, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(_agentLiveElapsedText, 2); + headerGrid.Children.Add(_agentLiveElapsedText); + container.Children.Add(headerGrid); + + var card = new Border + { + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(13, 10, 13, 10), + }; + card.SetResourceReference(Border.BackgroundProperty, "ItemBackground"); + + var cardStack = new StackPanel(); + _agentLiveStatusText = new TextBlock + { + Text = "준비 중...", + FontSize = 12, + FontFamily = new FontFamily("Segoe UI, Malgun Gothic"), + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + }; + cardStack.Children.Add(_agentLiveStatusText); + + _agentLiveSubItems = new StackPanel { Margin = new Thickness(0, 6, 0, 0) }; + cardStack.Children.Add(_agentLiveSubItems); + + card.Child = cardStack; + container.Children.Add(card); + + _agentLiveContainer = container; + AddTranscriptElement(container); + ForceScrollToEnd(); + + _agentLiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _agentLiveElapsedTimer.Tick += (_, _) => + { + if (_agentLiveElapsedText == null) + return; + + var sec = (int)(DateTime.UtcNow - _agentLiveStartTime).TotalSeconds; + _agentLiveElapsedText.Text = sec > 0 ? $"{sec}초 경과" : ""; + }; + _agentLiveElapsedTimer.Start(); + } + + private void UpdateAgentLiveCard(string message, string? subItem = null, + string? category = null, bool clearSubItems = false) + { + if (_agentLiveContainer == null || _agentLiveStatusText == null) return; + + _agentLiveStatusText.Text = message; + + if (clearSubItems || (category != null && category != _agentLiveCurrentCategory)) + { + _agentLiveSubItemTexts.Clear(); + _agentLiveSubItems?.Children.Clear(); + if (category != null) + _agentLiveCurrentCategory = category; + } + + if (string.IsNullOrEmpty(subItem) || _agentLiveSubItemTexts.Contains(subItem)) + return; + + _agentLiveSubItemTexts.Add(subItem); + const int maxLiveSubItems = 8; + if (_agentLiveSubItemTexts.Count > maxLiveSubItems) + { + _agentLiveSubItemTexts.RemoveAt(0); + if (_agentLiveSubItems?.Children.Count > 0) + _agentLiveSubItems.Children.RemoveAt(0); + } + + var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var tb = new TextBlock + { + Text = $"› {subItem}", + FontSize = 10.5, + FontFamily = new FontFamily("Segoe UI, Malgun Gothic"), + Foreground = secondary, + Opacity = 0.62, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 520, + Margin = new Thickness(0, 1, 0, 0), + }; + _agentLiveSubItems?.Children.Add(tb); + ForceScrollToEnd(); + } + + private void RemoveAgentLiveCard(bool animated = true) + { + _agentLiveElapsedTimer?.Stop(); + _agentLiveElapsedTimer = null; + + if (_agentLiveContainer == null) + return; + + var toRemove = _agentLiveContainer; + _agentLiveContainer = null; + _agentLiveStatusText = null; + _agentLiveSubItems = null; + _agentLiveElapsedText = null; + _agentLiveSubItemTexts.Clear(); + _agentLiveCurrentCategory = null; + + if (animated && ContainsTranscriptElement(toRemove) && !IsLightweightLiveProgressMode()) + { + var anim = new DoubleAnimation(toRemove.Opacity, 0, TimeSpan.FromMilliseconds(160)); + anim.Completed += (_, _) => RemoveTranscriptElement(toRemove); + toRemove.BeginAnimation(UIElement.OpacityProperty, anim); + return; + } + + RemoveTranscriptElement(toRemove); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs index 0a4bd97..0d64185 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs @@ -10,6 +10,16 @@ namespace AxCopilot.Views; public partial class ChatWindow { + private UIElement CreateMessageBubbleElement(string role, string content, bool animate = true, ChatMessage? message = null) + { + var beforeCount = GetTranscriptElementCount(); + AddMessageBubble(role, content, animate, message); + var element = GetTranscriptElementAt(beforeCount) + ?? throw new InvalidOperationException("메시지 버블을 생성하지 못했습니다."); + RemoveTranscriptElementAt(beforeCount); + return element; + } + private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null) { var isUser = role == "user"; diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index cd828d4..3f8c37d 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -116,11 +116,21 @@ public partial class ChatWindow var cacheKey = $"m_{msg.MsgId}"; timeline.Add((cacheKey, msg.Timestamp, 0, () => { - // 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성) - if (_elementCache.TryGetValue(cacheKey, out var cached)) - AddTranscriptElement(cached); - else - AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg); + 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; + }); })); } diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs b/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs index 6bde5aa..961ef4c 100644 --- a/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs +++ b/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; @@ -7,7 +9,8 @@ namespace AxCopilot.Views; public partial class ChatWindow { - private readonly ObservableCollection _transcriptElements = []; + private readonly ObservableCollection _transcriptElements = []; + private readonly Dictionary _transcriptElementMap = new(); private ScrollViewer? _transcriptScrollViewer; private bool _transcriptScrollHooked; @@ -55,6 +58,8 @@ public partial class ChatWindow return null; } + private static string CreateTranscriptElementKey() => $"ui_{Guid.NewGuid():N}"; + private int GetTranscriptElementCount() => _transcriptElements.Count; private UIElement? GetTranscriptElementAt(int index) @@ -62,24 +67,73 @@ public partial class ChatWindow if (index < 0 || index >= _transcriptElements.Count) return null; - return _transcriptElements[index]; + return _transcriptElements[index].GetOrCreateElement(); } - private int GetTranscriptElementIndex(UIElement element) => _transcriptElements.IndexOf(element); + private int GetTranscriptElementIndex(UIElement element) + { + if (_transcriptElementMap.TryGetValue(element, out var item)) + return _transcriptElements.IndexOf(item); - private bool ContainsTranscriptElement(UIElement element) => _transcriptElements.Contains(element); + for (var i = 0; i < _transcriptElements.Count; i++) + { + var candidate = _transcriptElements[i]; + if (!candidate.IsMaterialized) + continue; - private void AddTranscriptElement(UIElement element) => _transcriptElements.Add(element); + if (ReferenceEquals(candidate.Element, element)) + { + _transcriptElementMap[element] = candidate; + return i; + } + } - private void ClearTranscriptElements() => _transcriptElements.Clear(); + return -1; + } - private void RemoveTranscriptElement(UIElement element) => _transcriptElements.Remove(element); + private bool ContainsTranscriptElement(UIElement element) => GetTranscriptElementIndex(element) >= 0; + + private TranscriptVisualItem WrapTranscriptElement(string key, UIElement element) + { + var item = new TranscriptVisualItem(key, element); + _transcriptElementMap[element] = item; + return item; + } + + private void AddTranscriptElement(UIElement element) + => _transcriptElements.Add(WrapTranscriptElement(CreateTranscriptElementKey(), element)); + + private void AddDeferredTranscriptElement(string key, Func elementFactory) + { + TranscriptVisualItem? item = null; + item = new TranscriptVisualItem( + key, + elementFactory, + element => _transcriptElementMap[element] = item!); + _transcriptElements.Add(item); + } + + private void ClearTranscriptElements() + { + _transcriptElements.Clear(); + _transcriptElementMap.Clear(); + } + + private void RemoveTranscriptElement(UIElement element) + { + var index = GetTranscriptElementIndex(element); + if (index >= 0) + RemoveTranscriptElementAt(index); + } private void RemoveTranscriptElementAt(int index) { if (index < 0 || index >= _transcriptElements.Count) return; + var item = _transcriptElements[index]; + if (item.Element != null) + _transcriptElementMap.Remove(item.Element); _transcriptElements.RemoveAt(index); } @@ -88,7 +142,11 @@ public partial class ChatWindow if (index < 0 || index >= _transcriptElements.Count) return; - _transcriptElements[index] = element; + var previous = _transcriptElements[index]; + if (previous.Element != null) + _transcriptElementMap.Remove(previous.Element); + + _transcriptElements[index] = WrapTranscriptElement(CreateTranscriptElementKey(), element); } private double GetTranscriptScrollableHeight() => _transcriptScrollViewer?.ScrollableHeight ?? 0; diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index b755fb2..8d7c671 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" + xmlns:local="clr-namespace:AxCopilot.Views" Title="AX Copilot — AX Agent" Width="1180" Height="880" MinWidth="780" MinHeight="560" @@ -1378,6 +1379,11 @@ + + + + +