구조 개선: transcript 지연 가상화와 tool executor 분리 적용
이번 변경은 claude-code 기준 구조 강건성을 높이기 위한 리팩터링입니다. 핵심 수정 사항: - AX Agent transcript를 TranscriptVisualItem/TranscriptVisualHost 기반 지연 materialization 구조로 전환해 MessageList 가상화 기반을 강화했습니다. - StreamingToolExecutionCoordinator를 IToolExecutionCoordinator 뒤로 분리해 AgentLoopService가 구체 구현에 직접 묶이지 않도록 정리했습니다. - 라이브 진행 카드 렌더를 ChatWindow.LiveProgressPresentation partial로 이동해 ChatWindow.xaml.cs의 책임을 더 줄였습니다. - 기존 메시지 bubble 조립 로직을 유지하면서 transcript host가 필요 시점에 bubble을 만들 수 있도록 helper 경로를 추가했습니다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\ - 경고 0 / 오류 0
This commit is contained in:
10
README.md
10
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<UIElement>` 직접 주입 구조에서 `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 합계를 다시 계산해 상태바에 유지합니다.
|
||||
|
||||
BIN
dist/AxCopilot/AxCopilot.exe
vendored
BIN
dist/AxCopilot/AxCopilot.exe
vendored
Binary file not shown.
BIN
dist/AxCopilot_Setup.exe
vendored
BIN
dist/AxCopilot_Setup.exe
vendored
Binary file not shown.
@@ -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<UIElement> 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.
|
||||
|
||||
|
||||
@@ -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<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||
|
||||
22
src/AxCopilot/Services/Agent/IToolExecutionCoordinator.cs
Normal file
22
src/AxCopilot/Services/Agent/IToolExecutionCoordinator.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal interface IToolExecutionCoordinator
|
||||
{
|
||||
Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
LlmService.ContentBlock block,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
AgentContext context,
|
||||
CancellationToken ct);
|
||||
|
||||
Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
CancellationToken ct,
|
||||
string phaseLabel,
|
||||
AgentLoopService.RunState? runState = null,
|
||||
bool forceToolCall = false,
|
||||
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal sealed class StreamingToolExecutionCoordinator
|
||||
internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordinator
|
||||
{
|
||||
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
||||
208
src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs
Normal file
208
src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -116,11 +116,21 @@ public partial class ChatWindow
|
||||
var cacheKey = $"m_{msg.MsgId}";
|
||||
timeline.Add((cacheKey, msg.Timestamp, 0, () =>
|
||||
{
|
||||
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
|
||||
AddDeferredTranscriptElement(
|
||||
cacheKey,
|
||||
() =>
|
||||
{
|
||||
if (_elementCache.TryGetValue(cacheKey, out var cached))
|
||||
AddTranscriptElement(cached);
|
||||
else
|
||||
AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg);
|
||||
return cached;
|
||||
|
||||
var element = CreateMessageBubbleElement(
|
||||
capturedMsg.Role,
|
||||
capturedMsg.Content,
|
||||
animate: false,
|
||||
message: capturedMsg);
|
||||
_elementCache[cacheKey] = element;
|
||||
return element;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UIElement> _transcriptElements = [];
|
||||
private readonly ObservableCollection<TranscriptVisualItem> _transcriptElements = [];
|
||||
private readonly Dictionary<UIElement, TranscriptVisualItem> _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<UIElement> 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;
|
||||
|
||||
@@ -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 @@
|
||||
<VirtualizingStackPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type local:TranscriptVisualItem}">
|
||||
<local:TranscriptVisualHost/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
|
||||
@@ -3530,6 +3530,7 @@ public partial class ChatWindow : Window
|
||||
|
||||
private void ShowDropActionMenu(string[] files)
|
||||
{
|
||||
if (files == null || files.Length == 0) return;
|
||||
// 파일 유형 판별
|
||||
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
|
||||
string category;
|
||||
@@ -4243,7 +4244,9 @@ public partial class ChatWindow : Window
|
||||
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase))
|
||||
return ("open", "");
|
||||
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 1)
|
||||
if (parts.Length == 0)
|
||||
return ("open", "");
|
||||
if (parts.Length == 1)
|
||||
return (parts[0].Trim().ToLowerInvariant(), "");
|
||||
return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
|
||||
}
|
||||
@@ -5515,7 +5518,8 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
|
||||
private AgentLoopService GetAgentLoop(string tab) =>
|
||||
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.First();
|
||||
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No agent loop registered for tab '{tab}'");
|
||||
|
||||
private async Task<string> RunAgentLoopAsync(
|
||||
string runTab,
|
||||
@@ -6840,200 +6844,6 @@ public partial class ChatWindow : Window
|
||||
|
||||
// ─── 채팅창 내 에이전트 라이브 진행 카드 ─────────────────────────────────────
|
||||
|
||||
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 accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||
|
||||
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 System.Windows.Media.Animation.QuadraticEase
|
||||
{ EasingMode = System.Windows.Media.Animation.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 = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
||||
EasingFunction = new System.Windows.Media.Animation.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 System.Windows.Media.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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
_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 System.Windows.Media.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);
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveTranscriptElement(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 미니 다이아몬드 아이콘 애니메이션 ───────────────────────────────────────
|
||||
|
||||
private void StartStatusDiamondAnimation()
|
||||
|
||||
30
src/AxCopilot/Views/TranscriptVisualHost.cs
Normal file
30
src/AxCopilot/Views/TranscriptVisualHost.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public sealed class TranscriptVisualHost : ContentControl
|
||||
{
|
||||
public TranscriptVisualHost()
|
||||
{
|
||||
DataContextChanged += (_, _) => SyncContent();
|
||||
Loaded += (_, _) => SyncContent();
|
||||
}
|
||||
|
||||
private void SyncContent()
|
||||
{
|
||||
if (DataContext is TranscriptVisualItem item)
|
||||
{
|
||||
Content = item.GetOrCreateElement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is UIElement element)
|
||||
{
|
||||
Content = element;
|
||||
return;
|
||||
}
|
||||
|
||||
Content = null;
|
||||
}
|
||||
}
|
||||
43
src/AxCopilot/Views/TranscriptVisualItem.cs
Normal file
43
src/AxCopilot/Views/TranscriptVisualItem.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public sealed class TranscriptVisualItem
|
||||
{
|
||||
private readonly Func<UIElement>? _factory;
|
||||
private readonly Action<UIElement>? _onMaterialized;
|
||||
private UIElement? _element;
|
||||
|
||||
public TranscriptVisualItem(string key, UIElement element)
|
||||
{
|
||||
Key = key;
|
||||
_element = element;
|
||||
}
|
||||
|
||||
public TranscriptVisualItem(string key, Func<UIElement> factory, Action<UIElement>? onMaterialized = null)
|
||||
{
|
||||
Key = key;
|
||||
_factory = factory;
|
||||
_onMaterialized = onMaterialized;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public bool IsMaterialized => _element != null;
|
||||
|
||||
public UIElement? Element => _element;
|
||||
|
||||
public UIElement GetOrCreateElement()
|
||||
{
|
||||
if (_element != null)
|
||||
return _element;
|
||||
|
||||
if (_factory == null)
|
||||
throw new InvalidOperationException("Transcript visual factory is not available.");
|
||||
|
||||
_element = _factory();
|
||||
_onMaterialized?.Invoke(_element);
|
||||
return _element;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user