diff --git a/README.md b/README.md index 31bac08..67932dd 100644 --- a/README.md +++ b/README.md @@ -1608,3 +1608,8 @@ MIT License - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. +- 업데이트: 2026-04-10 09:03 (KST) + - Cowork/Code 실행 중 앱 화면이 끝날 때까지 멈춰 보이던 현상을 줄이기 위해, 에이전트 이벤트의 UI 전달 우선순위와 처리 경로를 다시 정리했습니다. + - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 agent dispatcher는 `DispatcherPriority.Normal` 대신 `Background`를 사용해 입력/렌더가 먼저 흐르도록 조정했습니다. + - 같은 파일의 `OnAgentEvent()`는 이벤트마다 라이브 카드와 상태 서브아이템을 즉시 갱신하지 않고, 완료/오류 같은 종료 신호만 즉시 처리한 뒤 나머지는 기존 배치 타이머로 넘기도록 단순화했습니다. + - [ChatWindow.AgentEventProcessor.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs)는 execution event를 UI 스레드와 백그라운드에서 두 번 append하던 구조를 제거하고, 백그라운드 단일 리더에서 한 번만 대화 히스토리를 반영하도록 바꿨습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f34812d..c8037fe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -555,3 +555,16 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs` - performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎. +## Cowork/Code 실행 중 UI 멈춤 완화 (2026-04-10 09:03 KST) + +- `claude-code`처럼 실행 이벤트를 얇게 소비하도록, AX Agent의 Cowork/Code UI 이벤트 경로를 다시 줄였습니다. +- `src/AxCopilot/Views/ChatWindow.xaml.cs` + - agent loop dispatcher 우선순위를 `DispatcherPriority.Normal`에서 `DispatcherPriority.Background`로 낮춰, 대량의 에이전트 이벤트가 들어와도 사용자 입력과 렌더가 먼저 처리되도록 바꿨습니다. + - `OnAgentEvent()`는 이제 완료/오류 같은 종료 이벤트만 즉시 처리하고, ToolCall/ToolResult/Thinking의 라이브 카드 갱신은 기존 `_agentUiEventTimer` 기반 배치 경로로 넘깁니다. +- `src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs` + - execution event를 UI 스레드에서 즉시 append한 뒤 백그라운드에서 다시 append하던 중복 경로를 제거했습니다. + - 대화 히스토리 반영, agent run 기록, 저장은 백그라운드 단일 리더에서 한 번만 처리하도록 정리해 Cowork/Code 실행 중 UI 점유를 줄였습니다. +- 기대 효과 + - 긴 tool/thinking 이벤트가 연속으로 들어와도 창 전체가 끝날 때까지 얼어붙는 느낌이 줄어듭니다. + - transcript 저장과 실행 이력 반영이 한 번만 일어나, 실행 길이가 길수록 커지던 UI 스레드 부담이 완화됩니다. + diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs index 5e7b302..7d35564 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs @@ -8,12 +8,12 @@ using AxCopilot.Services.Agent; namespace AxCopilot.Views; /// -/// 에이전트 이벤트의 무거운 작업(대화 변이·저장)을 백그라운드 스레드에서 처리합니다. -/// UI 스레드는 시각적 피드백만 담당하여 채팅창 멈춤을 방지합니다. +/// 에이전트 이벤트의 무거운 작업(대화 반영, 저장, 실행 이력 기록)을 +/// 백그라운드 단일 리더에서 배치 처리해 UI 스레드 점유를 줄입니다. /// public partial class ChatWindow { - /// 백그라운드 처리 대상 이벤트 큐 아이템. + /// 백그라운드 처리 대상 에이전트 이벤트 단위입니다. private readonly record struct AgentEventWorkItem( AgentEvent Event, string EventTab, @@ -26,50 +26,31 @@ public partial class ChatWindow private Task? _agentEventProcessorTask; - /// 백그라운드 이벤트 프로세서를 시작합니다. 창 초기화 시 한 번 호출됩니다. + /// 백그라운드 이벤트 프로세서를 시작합니다. private void StartAgentEventProcessor() { _agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync); } - /// 백그라운드 이벤트 프로세서를 종료합니다. 창 닫기 시 호출됩니다. + /// 백그라운드 이벤트 프로세서를 종료합니다. private void StopAgentEventProcessor() { _agentEventChannel.Writer.TryComplete(); - // 프로세서 완료를 동기 대기하지 않음 — 데드락 방지 - // GC가 나머지를 정리합니다. + // 종료 대기는 생략한다. 남은 정리는 GC와 종료 루틴에 맡긴다. } /// /// 에이전트 이벤트를 백그라운드 큐에 추가합니다. - /// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지). - /// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다. + /// 대화 히스토리 반영과 저장은 프로세서에서 한 번만 수행합니다. /// private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender) { - // ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ── - try - { - lock (_convLock) - { - var session = _appState.ChatSession; - if (session != null) - { - var result = _chatEngine.AppendExecutionEvent( - session, null!, _currentConversation, _activeTab, eventTab, evt); - _currentConversation = result.CurrentConversation; - } - } - } - catch (Exception ex) - { - LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}"); - } - _agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender)); } - /// 백그라운드 전용: 채널에서 이벤트를 배치로 읽어 대화 변이 + 저장을 수행합니다. + /// + /// 백그라운드 전용: 채널에 쌓인 이벤트를 배치로 읽어 대화 반영과 저장을 수행합니다. + /// private async Task ProcessAgentEventsAsync() { var reader = _agentEventChannel.Reader; @@ -97,7 +78,6 @@ public partial class ChatWindow var eventTab = work.EventTab; var activeTab = work.ActiveTab; - // ── 대화 변이: execution event 추가 ── try { lock (_convLock) @@ -117,7 +97,6 @@ public partial class ChatWindow LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}"); } - // ── 대화 변이: agent run 추가 (Complete/Error) ── if (evt.Type == AgentEventType.Complete) { try @@ -139,6 +118,7 @@ public partial class ChatWindow { LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}"); } + hasTerminalEvent = true; } else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName)) @@ -162,6 +142,7 @@ public partial class ChatWindow { LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}"); } + hasTerminalEvent = true; } @@ -169,7 +150,6 @@ public partial class ChatWindow anyNeedsRender = true; } - // ── 디바운스 저장: 2초마다 또는 종료 이벤트 시 즉시 ── if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000)) { try @@ -182,11 +162,11 @@ public partial class ChatWindow { LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}"); } + pendingPersist = null; persistStopwatch.Restart(); } - // ── UI 새로고침 신호: 배치 전체에 대해 단 1회 ── if (anyNeedsRender) { try @@ -195,7 +175,10 @@ public partial class ChatWindow () => ScheduleExecutionHistoryRender(autoScroll: true), DispatcherPriority.Background); } - catch { /* 앱 종료 중 무시 */ } + catch + { + // 창 종료 중에는 무시한다. + } } } } @@ -206,10 +189,16 @@ public partial class ChatWindow LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}"); } - // ── 종료 시 미저장 대화 플러시 ── if (pendingPersist != null) { - try { _storage.Save(pendingPersist); } catch { } + try + { + _storage.Save(pendingPersist); + } + catch + { + // 종료 중 마지막 저장 실패는 무시한다. + } } } } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 354817a..be4b97b 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -10,6 +10,7 @@ using Microsoft.Win32; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; +using AxCopilot.ViewModels; namespace AxCopilot.Views; @@ -18,15 +19,15 @@ public partial class ChatWindow : Window { private const string UnifiedAdminPassword = "axgo123!"; private bool IsOverlayEncryptionEnabled => _settings.Settings.Llm.EncryptionEnabled; - private readonly SettingsService _settings; - private readonly ChatStorageService _storage; + private readonly ISettingsService _settings; + private readonly IChatStorageService _storage; private readonly DraftQueueProcessorService _draftQueueProcessor = new(); - private readonly LlmService _llm; + private readonly ILlmService _llm; private readonly ToolRegistry _toolRegistry; private readonly Dictionary _agentLoops = new(); private readonly AxAgentExecutionEngine _chatEngine; - private readonly ModelRouterService _router; - private AppStateService _appState => (System.Windows.Application.Current as App)?.AppState ?? new AppStateService(); + private readonly IModelRouterService _router; + private readonly AppStateService _appState; private readonly object _convLock = new(); private readonly Dictionary _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase); private ChatConversation? _currentConversation; @@ -59,6 +60,8 @@ public partial class ChatWindow : Window private readonly Dictionary _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase); private bool _failedOnlyFilter; private bool _runningOnlyFilter; + /// 아카이브 필터: null=일반만, true=아카이브만, false=전체 + private bool? _archiveFilter = null; private int _failedConversationCount; private int _runningConversationCount; private int _spotlightConversationCount; @@ -213,17 +216,24 @@ public partial class ChatWindow : Window public DateTime? LastCompletedAt { get; init; } public bool IsRunning { get; init; } public string WorkFolder { get; init; } = ""; + public bool Archived { get; init; } } + /// MVVM ViewModel — XAML 바인딩 대상. + internal ChatWindowViewModel ViewModel { get; } + public ChatWindow(SettingsService settings) { + ViewModel = new ChatWindowViewModel(); + DataContext = ViewModel; InitializeComponent(); InitializeTranscriptHost(); + _appState = (System.Windows.Application.Current as App)?.AppState ?? new AppStateService(); _settings = settings; _settings.SettingsChanged += Settings_SettingsChanged; - _storage = new ChatStorageService(); - _llm = new LlmService(settings); - _router = new ModelRouterService(settings); + _storage = ServiceLocator.Get(); + _llm = ServiceLocator.Get(); + _router = ServiceLocator.Get(); _toolRegistry = ToolRegistry.CreateDefault(); _chatEngine = new AxAgentExecutionEngine(); // 탭별 독립 에이전트 루프 생성 — Chat/Cowork/Code 각각 병렬 실행 가능 @@ -263,8 +273,8 @@ public partial class ChatWindow : Window _inputUiRefreshTimer.Stop(); _inputUiRefreshTimer.Interval = _isStreaming ? (IsLightweightLiveProgressMode() - ? TimeSpan.FromMilliseconds(900) - : TimeSpan.FromMilliseconds(250)) + ? TimeSpan.FromMilliseconds(2000) + : TimeSpan.FromMilliseconds(1500)) : TimeSpan.FromMilliseconds(250); RefreshContextUsageVisual(); RefreshDraftQueueUi(); @@ -273,11 +283,13 @@ public partial class ChatWindow : Window _executionHistoryRenderTimer.Tick += (_, _) => { _executionHistoryRenderTimer.Stop(); - // 스트리밍 중에는 전체 재렌더링 빈도를 줄여 UI 부하 감소 + // 스트리밍 중에는 재렌더링 빈도를 크게 줄여 UI 부하 감소 + // Claude Desktop은 React virtual DOM으로 diff만 적용하지만 + // WPF는 전체 시각적 트리를 재구성하므로 간격이 넉넉해야 합니다 _executionHistoryRenderTimer.Interval = _isStreaming ? (IsLightweightLiveProgressMode() - ? TimeSpan.FromMilliseconds(2200) - : TimeSpan.FromMilliseconds(1500)) + ? TimeSpan.FromMilliseconds(8000) + : TimeSpan.FromMilliseconds(5000)) : TimeSpan.FromMilliseconds(350); RenderMessages(preserveViewport: true); if (_pendingExecutionHistoryAutoScroll) @@ -290,8 +302,8 @@ public partial class ChatWindow : Window _taskSummaryRefreshTimer.Stop(); _taskSummaryRefreshTimer.Interval = _isStreaming ? (IsLightweightLiveProgressMode() - ? TimeSpan.FromMilliseconds(1200) - : TimeSpan.FromMilliseconds(800)) + ? TimeSpan.FromMilliseconds(3000) + : TimeSpan.FromMilliseconds(2000)) : TimeSpan.FromMilliseconds(120); UpdateTaskSummaryIndicators(); }; @@ -307,8 +319,8 @@ public partial class ChatWindow : Window _agentUiEventTimer.Stop(); _agentUiEventTimer.Interval = _isStreaming ? (IsLightweightLiveProgressMode() - ? TimeSpan.FromMilliseconds(500) - : TimeSpan.FromMilliseconds(350)) + ? TimeSpan.FromMilliseconds(2000) + : TimeSpan.FromMilliseconds(1500)) : TimeSpan.FromMilliseconds(200); FlushPendingAgentUiEvent(); }; @@ -385,7 +397,7 @@ public partial class ChatWindow : Window InitPreviewSplitButtonHover(); InitPlanButtonHover(); - Dispatcher.BeginInvoke(() => + Dispatcher.BeginInvoke(new Action(() => { TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); RestoreLastConversations(); @@ -402,7 +414,7 @@ public partial class ChatWindow : Window if (retention > 0) _storage.PurgeExpired(retention); _storage.PurgeForDiskSpace(); }); - }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); + }), System.Windows.Threading.DispatcherPriority.Loaded); // 입력 바 포커스 글로우 효과 var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; @@ -762,8 +774,20 @@ public partial class ChatWindow : Window _currentConversation.Title = newTitle; } - ChatTitle.Text = newTitle; - try { if (ChatSession == null) { ChatConversation conv; lock (_convLock) conv = _currentConversation!; _storage.Save(conv); } } catch { } + ViewModel.ChatTitle = newTitle; + try + { + if (ChatSession == null) + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv != null) _storage.Save(conv); + } + } + catch (Exception ex) + { + Services.LogService.Warn($"제목 변경 저장 실패: {ex.Message}"); + } RefreshConversationList(); } @@ -828,7 +852,7 @@ public partial class ChatWindow : Window var iconTb = new TextBlock { - Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), + Text = icon, FontFamily = s_segoeIconFont, FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(iconTb, 0); @@ -1301,6 +1325,7 @@ public partial class ChatWindow : Window _elapsedLabel = null; _cachedStreamContent = ""; _streamingTabs.Clear(); + ViewModel.IsStreaming = false; _streamRunTab = null; _streamStartTime = default; BtnSend.IsEnabled = true; @@ -1394,6 +1419,7 @@ public partial class ChatWindow : Window private void UpdateTabUI() { + ViewModel.ActiveTab = _activeTab; ApplyAgentThemeResources(); ApplyExpressionLevelUi(); ApplySidebarStateForActiveTab(animated: false); @@ -1739,8 +1765,7 @@ public partial class ChatWindow : Window return; } - FolderPathLabel.Text = path; - FolderPathLabel.ToolTip = path; + ViewModel.WorkFolder = path; lock (_convLock) { @@ -1794,8 +1819,7 @@ public partial class ChatWindow : Window private void BtnFolderClear_Click(object sender, RoutedEventArgs e) { - FolderPathLabel.Text = "폴더를 선택하세요"; - FolderPathLabel.ToolTip = null; + ViewModel.WorkFolder = ""; lock (_convLock) { if (_currentConversation != null) @@ -2100,7 +2124,7 @@ public partial class ChatWindow : Window var sp = new StackPanel { Orientation = Orientation.Horizontal }; sp.Children.Add(new TextBlock { - Text = "\uE8A5", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10, + Text = "\uE8A5", FontFamily = s_segoeIconFont, FontSize = 10, Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), }); sp.Children.Add(new TextBlock @@ -2111,7 +2135,7 @@ public partial class ChatWindow : Window }); var removeBtn = new Button { - Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush }, + Content = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 8, Foreground = secondaryBrush }, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), }; @@ -2308,7 +2332,8 @@ public partial class ChatWindow : Window lock (_convLock) { conversation = _currentConversation; - ChatTitle.Text = conversation?.Title ?? ""; + var title = conversation?.Title ?? ""; + ViewModel.ChatTitle = title; } UpdateSelectedPresetGuide(conversation); @@ -2415,6 +2440,14 @@ public partial class ChatWindow : Window StatusElapsed.Text = $"{sec}초"; StatusElapsed.Visibility = Visibility.Visible; } + + // 입력창 위 스트리밍 메트릭 레이블 갱신 (경과 시간 반영) + if (_isStreaming) + { + var cumTokens = (int)(_tabCumulativeInputTokens.GetValueOrDefault(_activeTab) + + _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab)); + UpdateStreamMetricsLabel(cumTokens); + } } private void TypingTimer_Tick(object? sender, EventArgs e) @@ -2593,6 +2626,12 @@ public partial class ChatWindow : Window if (TryHandleSlashNavigationKey(e)) return; + if (e.Key == Key.Tab && TryAcceptTopFileMentionSuggestion()) + { + e.Handled = true; + return; + } + // Ctrl+V: 클립보드 이미지 붙여넣기 if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) { @@ -2721,7 +2760,7 @@ public partial class ChatWindow : Window // base64에서 썸네일 생성 sp.Children.Add(new TextBlock { - Text = "\uE8B9", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, + Text = "\uE8B9", FontFamily = s_segoeIconFont, FontSize = 16, Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(2, 0, 4, 0), }); @@ -2738,7 +2777,7 @@ public partial class ChatWindow : Window var capturedChip = chip; var removeBtn = new Button { - Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush }, + Content = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 8, Foreground = secondaryBrush }, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), }; @@ -2771,6 +2810,7 @@ public partial class ChatWindow : Window UpdateInputBoxHeight(); ScheduleInputUiRefresh(); var text = InputBox.Text; + RefreshFileMentionSuggestions(text); // 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제 if (_slashPalette.ActiveCommand != null && text.StartsWith("/")) @@ -2844,657 +2884,6 @@ public partial class ChatWindow : Window _inputUiRefreshTimer.Start(); } - private static string BuildSlashSkillLabel(SkillDefinition skill) - { - var badge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase) - ? "[FORK]" - : "[DIRECT]"; - var baseLabel = $"{badge} {skill.Label}"; - return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}"; - } - - private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true) - { - var map = _settings.Settings.Llm.SlashPaletteSections; - if (map != null && map.TryGetValue(sectionKey, out var expanded)) - return expanded; - return defaultValue; - } - - private void SetSlashSectionExpanded(string sectionKey, bool expanded) - { - var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - map[sectionKey] = expanded; - try { _settings.Save(); } catch { } - } - - private bool AreAllSlashSectionsExpanded() - { - var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); - var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); - return commandsExpanded && skillsExpanded; - } - - private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e) - { - var expandAll = !AreAllSlashSectionsExpanded(); - SetSlashSectionExpanded("slash_commands", expandAll); - SetSlashSectionExpanded("slash_skills", expandAll); - _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); - RenderSlashPage(); - } - - private void BtnSlashReset_Click(object sender, RoutedEventArgs e) - { - _settings.Settings.Llm.FavoriteSlashCommands.Clear(); - _settings.Settings.Llm.RecentSlashCommands.Clear(); - try { _settings.Save(); } catch { } - _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); - RenderSlashPage(); - } - - private Dictionary BuildRecentSlashRankMap() - { - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - var recent = _settings.Settings.Llm.RecentSlashCommands; - for (var i = 0; i < recent.Count; i++) - { - var key = recent[i]?.Trim(); - if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) - continue; - map[key] = i; // index 낮을수록 최근 - } - return map; - } - - private Dictionary BuildFavoriteSlashRankMap() - { - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - var fav = _settings.Settings.Llm.FavoriteSlashCommands; - var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); - for (var i = 0; i < fav.Count; i++) - { - if (i >= maxFavorites) - break; - var key = fav[i]?.Trim(); - if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) - continue; - map[key] = i; // index 낮을수록 우선 - } - return map; - } - - private void RegisterRecentSlashCommand(string cmd) - { - if (string.IsNullOrWhiteSpace(cmd)) - return; - var recent = _settings.Settings.Llm.RecentSlashCommands; - var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50); - recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase)); - recent.Insert(0, cmd); - if (recent.Count > maxRecent) - recent.RemoveRange(maxRecent, recent.Count - maxRecent); - try { _settings.Save(); } catch { } - } - - private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches) - { - var commandExpanded = GetSlashSectionExpanded("slash_commands", true); - var skillExpanded = GetSlashSectionExpanded("slash_skills", true); - for (var i = 0; i < matches.Count; i++) - { - var visible = matches[i].IsSkill ? skillExpanded : commandExpanded; - if (visible) - return i; - } - return -1; - } - - private bool IsSlashItemVisibleByIndex(int index) - { - if (index < 0 || index >= _slashPalette.Matches.Count) - return false; - var item = _slashPalette.Matches[index]; - return item.IsSkill - ? GetSlashSectionExpanded("slash_skills", true) - : GetSlashSectionExpanded("slash_commands", true); - } - - private IReadOnlyList GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder; - - /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. - private void RenderSlashPage() - { - SlashItems.Items.Clear(); - _slashVisibleItemByAbsoluteIndex.Clear(); - _slashVisibleAbsoluteOrder.Clear(); - var total = _slashPalette.Matches.Count; - var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); - var totalCommands = total - totalSkills; - var favoriteRank = BuildFavoriteSlashRankMap(); - var recentRank = BuildRecentSlashRankMap(); - var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); - - SlashPopupTitle.Text = "명령 및 스킬"; - SlashPopupHint.Text = expressionLevel switch - { - "simple" => $"명령 {totalCommands} · 스킬 {totalSkills}", - "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동", - _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행", - }; - - var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); - var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); - if (SlashToggleGroupsLabel != null) - SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기"; - - Border CreateSlashSectionHeader(string key, string title, int count, bool expanded) - { - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; - - var header = new Border - { - Background = Brushes.Transparent, - BorderBrush = Brushes.Transparent, - BorderThickness = new Thickness(0), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(0, 4, 0, 2), - Cursor = Cursors.Hand, - }; - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - grid.Children.Add(new TextBlock - { - Text = expanded ? "\uE70D" : "\uE76C", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 6, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - var titleText = new TextBlock - { - Text = $"{title} {count}", - FontSize = 10.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(titleText, 1); - grid.Children.Add(titleText); - var metaText = new TextBlock - { - Text = expanded ? "접기" : "펼치기", - FontSize = 9.5, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(metaText, 2); - grid.Children.Add(metaText); - - header.Child = grid; - header.MouseEnter += (_, _) => header.Background = hoverBrushItem; - header.MouseLeave += (_, _) => header.Background = Brushes.Transparent; - header.MouseLeftButtonDown += (_, _) => - { - SetSlashSectionExpanded(key, !expanded); - _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); - RenderSlashPage(); - }; - return header; - } - - void AddSlashItem(int i) - { - var (cmd, label, isSkill) = _slashPalette.Matches[i]; - var isFavorite = favoriteRank.ContainsKey(cmd); - var isRecent = recentRank.ContainsKey(cmd); - var capturedCmd = cmd; - var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; - var skillAvailable = skillDef?.IsAvailable ?? true; - - var absoluteIndex = i; - var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; - - var item = new Border - { - Background = Brushes.Transparent, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - BorderThickness = new Thickness(0, 0, 0, 1), - CornerRadius = new CornerRadius(0), - Padding = new Thickness(8, 9, 8, 9), - Margin = new Thickness(0, 0, 0, 0), - Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow, - Opacity = skillAvailable ? 1.0 : 0.5, - }; - var itemGrid = new Grid(); - itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var leftStack = new StackPanel(); - var titleRow = new StackPanel { Orientation = Orientation.Horizontal }; - titleRow.Children.Add(new TextBlock - { - Text = isSkill ? "\uE768" : "\uE9CE", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = skillAvailable ? accent : secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - titleRow.Children.Add(new TextBlock - { - Text = cmd, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = skillAvailable ? primaryText : secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - if (isFavorite) - { - titleRow.Children.Add(new Border - { - Background = BrushFromHex("#FEF3C7"), - BorderBrush = BrushFromHex("#F59E0B"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(5, 0, 5, 0), - Margin = new Thickness(6, 0, 0, 0), - Child = new TextBlock - { - Text = "핀", - FontSize = 9.5, - Foreground = BrushFromHex("#92400E"), - } - }); - } - if (isRecent) - { - titleRow.Children.Add(new Border - { - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(5, 0, 5, 0), - Margin = new Thickness(6, 0, 0, 0), - Child = new TextBlock - { - Text = "최근", - FontSize = 9.5, - Foreground = BrushFromHex("#3730A3"), - } - }); - } - leftStack.Children.Add(titleRow); - leftStack.Children.Add(new TextBlock - { - Text = label, - FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(20, 2, 0, 0), - TextTrimming = TextTrimming.CharacterEllipsis, - }); - Grid.SetColumn(leftStack, 0); - itemGrid.Children.Add(leftStack); - - var pinToggle = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(6, 4, 6, 4), - Margin = new Thickness(6, 0, 0, 0), - Cursor = Cursors.Hand, - Child = new TextBlock - { - Text = isFavorite ? "\uE77A" : "\uE718", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }, - ToolTip = isFavorite ? "핀 해제" : "핀 고정", - }; - pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem; - pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent; - pinToggle.MouseLeftButtonDown += (s, e) => - { - e.Handled = true; - ToggleSlashFavorite(capturedCmd); - }; - Grid.SetColumn(pinToggle, 1); - itemGrid.Children.Add(pinToggle); - - item.Child = itemGrid; - - if (skillAvailable) - { - item.MouseEnter += (_, _) => - { - _slashPalette.SelectedIndex = absoluteIndex; - UpdateSlashSelectionVisualState(); - }; - item.MouseLeave += (_, _) => UpdateSlashSelectionVisualState(); - item.MouseLeftButtonDown += (_, _) => - { - _slashPalette.SelectedIndex = absoluteIndex; - ExecuteSlashSelectedItem(); - }; - } - - SlashItems.Items.Add(item); - _slashVisibleItemByAbsoluteIndex[absoluteIndex] = item; - _slashVisibleAbsoluteOrder.Add(absoluteIndex); - } - - SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded)); - if (commandsExpanded) - { - var commandIndices = Enumerable.Range(0, total) - .Where(i => !_slashPalette.Matches[i].IsSkill) - .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) - .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) - .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); - foreach (var i in commandIndices) - { - AddSlashItem(i); - } - } - - SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded)); - if (skillsExpanded) - { - var skillIndices = Enumerable.Range(0, total) - .Where(i => _slashPalette.Matches[i].IsSkill) - .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) - .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) - .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); - foreach (var i in skillIndices) - { - AddSlashItem(i); - } - } - - var visibleCommandCount = commandsExpanded ? totalCommands : 0; - var visibleSkillCount = skillsExpanded ? totalSkills : 0; - if (visibleCommandCount + visibleSkillCount == 0) - { - SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기"; - } - else - { - SlashPopupFooter.Text = $"Enter 실행 · ↑↓/PgUp/PgDn 이동 · Home/End · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}"; - } - - UpdateSlashSelectionVisualState(); - EnsureSlashSelectionVisible(); - } - - /// 슬래시 팝업 마우스 휠 스크롤 처리. - private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e) - { - e.Handled = true; - SlashPopup_ScrollByDelta(e.Delta); - } - - private void MoveSlashSelection(int direction) - { - var visibleOrder = GetVisibleSlashOrderedIndices(); - if (visibleOrder.Count == 0) - return; - - var currentPosition = -1; - for (var i = 0; i < visibleOrder.Count; i++) - { - if (visibleOrder[i] != _slashPalette.SelectedIndex) - continue; - currentPosition = i; - break; - } - if (currentPosition < 0) - { - _slashPalette.SelectedIndex = visibleOrder[0]; - return; - } - - if (direction < 0 && currentPosition > 0) - _slashPalette.SelectedIndex = visibleOrder[currentPosition - 1]; - else if (direction > 0 && currentPosition < visibleOrder.Count - 1) - _slashPalette.SelectedIndex = visibleOrder[currentPosition + 1]; - } - - private int? FindSlashIndexClosestToViewportTop() - { - if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0) - return null; - - var bestIndex = -1; - var bestDistance = double.MaxValue; - foreach (var absoluteIndex in _slashVisibleAbsoluteOrder) - { - if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item)) - continue; - - try - { - var bounds = item.TransformToAncestor(SlashScrollViewer) - .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); - - // 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용. - var distance = Math.Abs(bounds.Top); - if (distance < bestDistance && bounds.Bottom >= 0) - { - bestDistance = distance; - bestIndex = absoluteIndex; - } - } - catch - { - // 레이아웃 갱신 중 transform 예외는 무시. - } - } - - return bestIndex >= 0 ? bestIndex : null; - } - - /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. - private void SlashPopup_ScrollByDelta(int delta) - { - if (_slashPalette.Matches.Count == 0) - return; - - if (GetVisibleSlashOrderedIndices().Count == 0) - { - if (SlashScrollViewer != null) - SlashScrollViewer.ScrollToVerticalOffset(Math.Max(0, SlashScrollViewer.VerticalOffset - delta / 3.0)); - return; - } - - // 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동. - if (SlashScrollViewer != null) - { - var target = Math.Max(0, Math.Min( - SlashScrollViewer.ScrollableHeight, - SlashScrollViewer.VerticalOffset - (delta / 3.0))); - SlashScrollViewer.ScrollToVerticalOffset(target); - } - - var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0)); - var direction = delta > 0 ? -1 : 1; - for (var i = 0; i < steps; i++) - MoveSlashSelection(direction); - - var viewportTopIndex = FindSlashIndexClosestToViewportTop(); - if (viewportTopIndex.HasValue) - _slashPalette.SelectedIndex = viewportTopIndex.Value; - - UpdateSlashSelectionVisualState(); - EnsureSlashSelectionVisible(); - } - - /// 키보드로 선택된 슬래시 아이템을 실행합니다. - private void ExecuteSlashSelectedItem() - { - var absoluteIdx = _slashPalette.SelectedIndex; - if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return; - - var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx]; - var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; - var skillAvailable = skillDef?.IsAvailable ?? true; - if (!skillAvailable) return; - RegisterRecentSlashCommand(cmd); - - SlashPopup.IsOpen = false; - _slashPalette.SelectedIndex = -1; - - if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase)) - { - InputBox.Text = ""; - ShowSlashHelpWindow(); - return; - } - ShowSlashChip(cmd); - InputBox.Focus(); - } - - private void EnsureSlashSelectionVisible() - { - if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0) - return; - - if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item)) - return; - - if (!IsVisualDescendantOf(item, SlashScrollViewer)) - return; - - Rect bounds; - try - { - bounds = item.TransformToAncestor(SlashScrollViewer) - .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); - } - catch - { - // 렌더 트리 갱신 중에는 transform이 실패할 수 있어 조용히 무시. - return; - } - - if (bounds.Top < 0) - SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8); - else if (bounds.Bottom > SlashScrollViewer.ViewportHeight) - SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8); - } - - private static bool IsVisualDescendantOf(DependencyObject? child, DependencyObject? parent) - { - if (child == null || parent == null) - return false; - - var current = child; - while (current != null) - { - if (ReferenceEquals(current, parent)) - return true; - current = VisualTreeHelper.GetParent(current); - } - - return false; - } - - private void UpdateSlashSelectionVisualState() - { - if (_slashVisibleItemByAbsoluteIndex.Count == 0) - return; - - var selectedIndex = _slashPalette.SelectedIndex; - var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; - - foreach (var (absoluteIndex, element) in _slashVisibleItemByAbsoluteIndex) - { - if (element is not Border border) - continue; - - var selected = absoluteIndex == selectedIndex; - border.Background = selected ? hoverBrushItem : Brushes.Transparent; - border.BorderBrush = selected ? accent : borderBrush; - border.BorderThickness = selected ? new Thickness(2, 0, 0, 1) : new Thickness(0, 0, 0, 1); - } - } - - /// 슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다. - private void ToggleSlashFavorite(string cmd) - { - var favs = _settings.Settings.Llm.FavoriteSlashCommands; - var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); - var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase)); - if (existing != null) - favs.Remove(existing); - else - { - favs.Add(cmd); - if (favs.Count > maxFavorites) - favs.RemoveRange(maxFavorites, favs.Count - maxFavorites); - } - - _settings.Save(); - - if (SlashPopup.IsOpen) - { - RenderSlashPage(); - return; - } - - // 팝업이 닫힌 경우에만 TextChanged 트리거 - var currentText = InputBox.Text; - InputBox.TextChanged -= InputBox_TextChanged; - InputBox.Text = ""; - InputBox.TextChanged += InputBox_TextChanged; - InputBox.Text = currentText; - } - - /// 슬래시 명령어 칩을 표시하고 InputBox를 비웁니다. - private void ShowSlashChip(string cmd) - { - _slashPalette.ActiveCommand = cmd; - SlashChipText.Text = cmd; - SlashCommandChip.Visibility = Visibility.Visible; - - // 칩 너비 측정 후 InputBox 왼쪽 여백 조정 - SlashCommandChip.UpdateLayout(); - var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6; - InputBox.Padding = new Thickness(chipRight, 10, 14, 10); - InputBox.Text = ""; - } - - /// 슬래시 명령어 칩을 숨깁니다. - /// true이면 InputBox에 명령어 텍스트를 복원합니다. - private void HideSlashChip(bool restoreText = false) - { - if (_slashPalette.ActiveCommand == null) return; - var prev = _slashPalette.ActiveCommand; - _slashPalette.ActiveCommand = null; - SlashCommandChip.Visibility = Visibility.Collapsed; - InputBox.Padding = new Thickness(14, 10, 14, 10); - if (restoreText) - { - InputBox.Text = prev + " "; - InputBox.CaretIndex = InputBox.Text.Length; - } - } - /// 슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다. private (string? slashSystem, string userText) ParseSlashCommand(string input) { @@ -3613,7 +3002,7 @@ public partial class ChatWindow : Window stack.Children.Add(new TextBlock { Text = icon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 13, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), @@ -3653,7 +3042,7 @@ public partial class ChatWindow : Window attachStack.Children.Add(new TextBlock { Text = "\uE723", - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), @@ -3746,12 +3135,12 @@ public partial class ChatWindow : Window }; var headerGrid = new Grid(); var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; - headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }); + headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = s_segoeIconFont, FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }); headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center }); headerGrid.Children.Add(headerStack); var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center }; - closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255)); closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent; closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); }; @@ -3930,8 +3319,8 @@ public partial class ChatWindow : Window UpdateChatTitle(); InputBox.Text = ""; EmptyState.Visibility = Visibility.Collapsed; - RenderMessages(preserveViewport: true); - ForceScrollToEnd(); + InvalidateTimelineCache(); + RenderMessages(preserveViewport: false); var llm = _settings.Settings.Llm; var working = conv.Messages.ToList(); @@ -3969,8 +3358,8 @@ public partial class ChatWindow : Window SaveLastConversations(); _storage.Save(conv); - RenderMessages(preserveViewport: true); - ForceScrollToEnd(); + InvalidateTimelineCache(); + RenderMessages(preserveViewport: false); if (StatusTokens != null) StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}"; SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false); @@ -4014,8 +3403,8 @@ public partial class ChatWindow : Window UpdateChatTitle(); InputBox.Text = ""; EmptyState.Visibility = Visibility.Collapsed; - RenderMessages(preserveViewport: true); - ForceScrollToEnd(); + InvalidateTimelineCache(); + RenderMessages(preserveViewport: false); } private string BuildSlashStatusText() @@ -5383,17 +4772,8 @@ public partial class ChatWindow : Window conv.Title = text.Length > 30 ? text[..30] + "…" : text; } } - SaveLastConversations(); - void TryPersistConversation(bool force = false) - { - if (!force && (DateTime.UtcNow - lastAutoSaveUtc).TotalSeconds < 1.2) - return; - - lastAutoSaveUtc = DateTime.UtcNow; - PersistConversationSnapshot(originTab, conv, "대화 중간 저장 실패"); - } - TryPersistConversation(force: true); + // ── UI 먼저 갱신: 사용자 메시지를 즉시 화면에 표시 ── UpdateChatTitle(); if (useComposerInput) { @@ -5401,7 +4781,20 @@ public partial class ChatWindow : Window UpdateInputBoxHeight(); } EmptyState.Visibility = Visibility.Collapsed; - RenderMessages(preserveViewport: true); + InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장 + RenderMessages(preserveViewport: false); // 사용자 메시지 전송 시 스크롤 하단 이동 포함 재렌더 + + // ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ── + var convForPersist = conv; + _ = Dispatcher.InvokeAsync(() => + { + try + { + SaveLastConversations(); + PersistConversationSnapshot(originTab, convForPersist, "대화 중간 저장 실패"); + } + catch { /* 초기 저장 실패는 무시 — 중간 저장에서 재시도 */ } + }, System.Windows.Threading.DispatcherPriority.Background); // 대화 통계 기록 Services.UsageStatisticsService.RecordChat(runTab); @@ -5411,10 +4804,21 @@ public partial class ChatWindow : Window _streamStartTime = DateTime.UtcNow; _streamingTabs.Add(runTab); + ViewModel.IsStreaming = true; _streamRunTab = runTab; _tabStreamCts[runTab] = new CancellationTokenSource(); + // 스트리밍 중 불필요한 타이머 일시 정지 — UI 스레드 부하 대폭 감소 + _gitRefreshTimer.Stop(); + _conversationSearchTimer.Stop(); + _responsiveLayoutTimer.Stop(); + _tokenUsagePopupCloseTimer.Stop(); StartLiveAgentProgressHints(); RefreshStreamingControlsForActiveTab(); + UpdateStreamMetricsLabel(); // 스트리밍 시작 시 메트릭 레이블 표시 + + // 스트리밍 시작 — 위에서 이미 RenderMessages(preserveViewport: false) 호출 완료 + // 이중 렌더 제거: 추가 RenderMessages 호출은 UI 스레드 멈춤의 주요 원인 + // 사용자 메시지는 line 4785에서 이미 렌더됨 var assistantContent = string.Empty; _activeStreamText = null; @@ -5457,12 +4861,20 @@ public partial class ChatWindow : Window if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; } - var preparedExecution = PrepareExecutionForConversation( - conv, - runTab, - slashSystem, - fileContext, - outboundImages); + // 시스템 프롬프트 빌드는 디스크 I/O(프로젝트 규칙, 메모리, 피드백)를 포함하므로 + // UI 스레드 블로킹 방지를 위해 백그라운드에서 실행 + var capturedConv = conv; + var capturedSlashSystem = slashSystem; + var capturedFileContext = fileContext; + var capturedImages = outboundImages; + var capturedRunTab = runTab; + var preparedExecution = await Task.Run(() => + PrepareExecutionForConversation( + capturedConv, + capturedRunTab, + capturedSlashSystem, + capturedFileContext, + capturedImages)); var executionMode = preparedExecution.Mode; sendMessages = preparedExecution.Messages; @@ -5527,8 +4939,8 @@ public partial class ChatWindow : Window Dispatcher = action => { var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; - // BeginInvoke(Normal): 에이전트 루프 스레드를 블록하지 않고 UI 이벤트를 비동기 전달. - appDispatcher.BeginInvoke(action, System.Windows.Threading.DispatcherPriority.Normal); + // Background 우선순위로 UI 입력/렌더를 먼저 흘려보내고 에이전트 이벤트를 비동기 전달. + appDispatcher.BeginInvoke(action, System.Windows.Threading.DispatcherPriority.Background); }, AskPermissionCallback = async (toolName, filePath) => { @@ -5654,11 +5066,25 @@ public partial class ChatWindow : Window _agentUiEventTimer.Stop(); HideStickyProgress(); StopRainbowGlow(); + ResetLiveProgressCardCache(); // 라이브 진행 카드 캐시 초기화 _activeStreamText = null; _elapsedLabel = null; _cachedStreamContent = ""; _streamStartTime = default; SetStatusIdle(); + UpdateStreamMetricsLabel(); // 스트리밍 종료 시 메트릭 레이블 숨김 + + // 스트리밍 종료 — 최종 트랜스크립트 렌더 + 연기된 레이아웃 갱신 + InvalidateTimelineCache(); + RenderMessages(preserveViewport: false); + + // 지연된 레이아웃 갱신 수행 + if (_pendingResponsiveLayoutRefresh) + { + _pendingResponsiveLayoutRefresh = false; + UpdateTopicPresetScrollMode(); + UpdateResponsiveChatLayout(); + } } RefreshStreamingControlsForActiveTab(); @@ -5737,6 +5163,7 @@ public partial class ChatWindow : Window string busyStatus) { _streamingTabs.Add(runTab); + ViewModel.IsStreaming = true; _streamRunTab = runTab; var streamCts = new CancellationTokenSource(); _tabStreamCts[runTab] = streamCts; @@ -5954,7 +5381,9 @@ public partial class ChatWindow : Window _pendingHiddenExecutionHistoryRender = true; return; } - if (IsLightweightLiveProgressMode() && _executionHistoryRenderTimer.IsEnabled) + // 스트리밍 중 타이머가 이미 실행 중이면 재시작하지 않음 (Stop+Start 루프 방지) + // 이전에는 매 이벤트마다 Stop→Start로 타이머가 영원히 지연되는 문제가 있었음 + if (_isStreaming && _executionHistoryRenderTimer.IsEnabled) return; _executionHistoryRenderTimer.Stop(); @@ -6125,439 +5554,6 @@ public partial class ChatWindow : Window }); } - // ─── 코워크 에이전트 지원 ──────────────────────────────────────────── - - private string BuildCoworkSystemPrompt() - { - var workFolder = GetCurrentWorkFolder(); - var llm = _settings.Settings.Llm; - var sb = new System.Text.StringBuilder(); - // ══════════════════════════════════════════════════════════════════ - // 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력 - // ══════════════════════════════════════════════════════════════════ - sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)"); - sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다."); - sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always"); - sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다."); - sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다."); - sb.AppendLine("Every single response MUST include at least one tool call."); - sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again."); - sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); - sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); - sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:"); - sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '작업을 시작하겠습니다' / '먼저 ... 하겠습니다'"); - sb.AppendLine(" ✗ '다음과 같이 진행하겠습니다' / '분석해보겠습니다' / '살펴보겠습니다'"); - sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...' / 'First, I need to ...'"); - sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 간단한 설명은 도구 결과 이후에만 허용됩니다."); - sb.AppendLine("CORRECT: call the tool immediately. A brief explanation may follow AFTER the tool result, never before."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 3] 한 번에 여러 도구를 호출하라 — Batch Multiple Tools"); - sb.AppendLine("독립적인 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 순차적으로 하지 마세요."); - sb.AppendLine("You MUST call multiple tools in a single response when tasks are independent. Never do them one by one."); - sb.AppendLine(" 나쁜 예(BAD): 응답1: folder_map → 응답2: document_read → 응답3: html_create (순차 처리 — 금지)"); - sb.AppendLine(" 좋은 예(GOOD): 응답1: folder_map + document_read 동시 호출 → 응답2: html_create (배치 처리)"); - sb.AppendLine(""); - sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty"); - sb.AppendLine("어떤 파일을 읽어야 할지 모를 때도 사용자에게 묻지 말고 즉시 folder_map이나 document_read를 호출하세요."); - sb.AppendLine("If unsure which file to read: call folder_map or document_read NOW. Do NOT ask the user."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 5] 완료까지 계속 도구를 호출하라 — Continue Until Complete"); - sb.AppendLine("사용자의 요청이 완전히 완료될 때까지 도구 호출을 계속하세요. '작업을 시작했습니다'는 완료가 아닙니다."); - sb.AppendLine("'완료'의 정의: 결과 파일이 실제로 존재하고, 검증되고, 사용자에게 보고된 상태."); - sb.AppendLine("Keep calling tools until fully done. 'Done' = output file exists + verified + reported to user."); - sb.AppendLine(""); - - // 소개 및 메타 정보 - sb.AppendLine("---"); - sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools."); - sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd})."); - sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환)."); - - sb.AppendLine("\nOnly present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely."); - sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact."); - sb.AppendLine("After creating files, summarize what was created and include the actual output path."); - sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered."); - sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs."); - sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates."); - sb.AppendLine("IMPORTANT: For reports, proposals, analyses, and manuals with multiple sections:"); - sb.AppendLine(" 1. Decide the document structure internally first."); - sb.AppendLine(" 2. Use document_plan only when it improves the actual output, not as a mandatory user-facing approval step."); - sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write."); - sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only."); - sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated."); - - // 문서 품질 검증 루프 - sb.AppendLine("\n## Document Quality Review"); - sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:"); - sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete"); - sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting"); - sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered"); - sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify"); - sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made"); - - // 문서 포맷 변환 지원 - sb.AppendLine("\n## Format Conversion"); - sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):"); - sb.AppendLine("1. Use file_read or document_read to read the source file content"); - sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)"); - sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible"); - - // 사용자 지정 출력 포맷 - var fmt = llm.DefaultOutputFormat; - if (!string.IsNullOrEmpty(fmt) && fmt != "auto") - { - var fmtMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["xlsx"] = "Excel (.xlsx) using excel_create", - ["docx"] = "Word (.docx) using docx_create", - ["html"] = "HTML (.html) using html_create", - ["md"] = "Markdown (.md) using markdown_create", - ["csv"] = "CSV (.csv) using csv_create", - }; - if (fmtMap.TryGetValue(fmt, out var fmtDesc)) - sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise."); - } - - // 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내 - if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern") - sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template."); - else - sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard."); - - if (!string.IsNullOrEmpty(workFolder)) - sb.AppendLine($"Current work folder: {workFolder}"); - sb.AppendLine($"File permission mode: {llm.FilePermission}"); - sb.Append(BuildSubAgentDelegationSection(false)); - - // 폴더 데이터 활용 지침 (사용자 옵션이 아닌 탭별 자동 정책) - switch (GetAutomaticFolderDataUsage()) - { - case "active": - sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available."); - sb.AppendLine("Use selective exploration. Start with glob or grep when the request already has a clear topic."); - sb.AppendLine("Use folder_map only when the workspace structure is unclear or the request is repo-wide/open-ended."); - sb.AppendLine("[CRITICAL] FILE SELECTION STRATEGY — DO NOT READ ALL FILES:"); - sb.AppendLine(" 1. Identify candidate files by filename or topic keywords first."); - sb.AppendLine(" 2. Read ONLY files that clearly match the user's topic. Skip unrelated topics."); - sb.AppendLine(" 3. Maximum 2-3 relevant files for the first pass. Expand only when evidence shows more files are needed."); - sb.AppendLine(" 4. Do NOT read every file 'just in case'. Broad reading without evidence is forbidden."); - sb.AppendLine(" 5. If no files match the topic, proceed WITHOUT reading any workspace files."); - sb.AppendLine("VIOLATION: Reading all files in the folder is FORBIDDEN. It wastes tokens and degrades quality."); - break; - case "passive": - sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " + - "Only read folder documents when the user explicitly asks you to reference or use them."); - break; - default: // "none" - sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path."); - break; - } - - // 프리셋 시스템 프롬프트가 있으면 추가 - lock (_convLock) - { - if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand)) - sb.AppendLine("\n" + _currentConversation.SystemCommand); - } - - // 프로젝트 문맥 파일 (AGENTS.md) 주입 - sb.Append(LoadProjectContext(workFolder)); - - // 프로젝트 규칙 (.ax/rules/) 자동 주입 - sb.Append(BuildProjectRulesSection(workFolder)); - - // 에이전트 메모리 주입 - sb.Append(BuildMemorySection(workFolder)); - - // 피드백 학습 컨텍스트 주입 - sb.Append(BuildFeedbackContext()); - - return sb.ToString(); - } - - private string BuildCodeSystemPrompt() - { - var workFolder = GetCurrentWorkFolder(); - var llm = _settings.Settings.Llm; - var code = llm.Code; - var sb = new System.Text.StringBuilder(); - - // ══════════════════════════════════════════════════════════════════ - // 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력 - // ══════════════════════════════════════════════════════════════════ - sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)"); - sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다."); - sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always"); - sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다."); - sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다."); - sb.AppendLine("Every single response MUST include at least one tool call."); - sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again."); - sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); - sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); - sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:"); - sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'"); - sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — folder_map을 즉시 호출하세요, 예고하지 마세요"); - sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'"); - sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 한 문장 설명은 도구 결과 이후에만 허용됩니다."); - sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, never precede it."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 3] 모든 독립 작업은 병렬로 호출하라 — Parallelise Everything Possible"); - sb.AppendLine("독립적인 읽기/검색 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 절대 순차적으로 하지 마세요."); - sb.AppendLine("You MUST call multiple tools in a single response for independent operations. Do this aggressively."); - sb.AppendLine(" 나쁜 예(BAD): R1: folder_map → R2: grep → R3: file_read → R4: file_edit (순차 — 금지)"); - sb.AppendLine(" 좋은 예(GOOD): R1: folder_map + grep + file_read 동시 호출 → R2: file_edit + build_run"); - sb.AppendLine("모든 독립적인 읽기/검색 작업은 같은 응답에 배치해야 합니다."); - sb.AppendLine("Every independent read/search operation MUST be batched into the same response."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty, Do Not Ask"); - sb.AppendLine("어떤 파일을 읽어야 할지 모를 때: 즉시 glob이나 grep을 호출하세요."); - sb.AppendLine("의존성이 불확실할 때: 즉시 dev_env_detect를 호출하세요."); - sb.AppendLine("If unsure which file: call glob or grep RIGHT NOW. If unsure about deps: call dev_env_detect RIGHT NOW."); - sb.AppendLine("'잘 모르겠습니다, 알려주시겠어요?' — 도구 호출로 답을 구할 수 있다면 절대 사용자에게 묻지 마세요."); - sb.AppendLine("Never say 'I'm not sure, could you clarify?' when a tool call would answer the question."); - sb.AppendLine(""); - sb.AppendLine("### [규칙 5] 도구로 검증하라, 가정하지 마라 — Verify with Tools, Not Assumptions"); - sb.AppendLine("편집 후에는 반드시 file_read로 최종 상태를 확인하세요. 편집이 성공했다고 가정하지 마세요."); - sb.AppendLine("빌드/테스트 후에는 build_run의 실제 출력을 인용하세요. '정상 작동할 것입니다'라고 말하지 마세요."); - sb.AppendLine("After editing: call file_read to confirm. Do not assume the edit succeeded."); - sb.AppendLine("After build/test: cite actual build_run output. Do not say 'it should work'."); - sb.AppendLine("'완료'의 정의: 빌드 통과 + 테스트 통과 + 편집 파일 재확인 + 결과 보고 — 모두 도구 출력으로 증명된 상태."); - sb.AppendLine("'Done' = build passes + tests pass + edited files re-read + result reported — all proven by tool output."); - sb.AppendLine(""); - - // 소개 및 메타 정보 - sb.AppendLine("---"); - sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development."); - sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd})."); - sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool."); - sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached."); - sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above."); - - sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)"); - sb.AppendLine("1. ORIENT: Choose the smallest exploration that matches the request."); - sb.AppendLine(" - If the request is file-specific, bug-specific, symbol-specific, or topic-specific: start with grep/glob and targeted file_read."); - sb.AppendLine(" - Only run folder_map (depth=2) when the repository structure is genuinely unclear or the request is repo-wide/open-ended."); - sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count."); - sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + targeted file_read to deeply understand the code you'll modify."); - sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points."); - sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior."); - sb.AppendLine(" - Keep the first reading pass narrow (usually 1-5 files) unless evidence shows the scope is larger."); - sb.AppendLine("4. PLAN: Build an internal execution outline and impact assessment before editing."); - sb.AppendLine(" - Present the outline explicitly only when the user asks for a plan or the change is clearly high risk."); - sb.AppendLine(" - Explain WHY each change is needed and what could break."); - sb.AppendLine(" - If you touch shared services, interfaces, models, controllers, view-models, app startup, or dependency registration, treat it as a high-impact change and call out the impact clearly."); - sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files."); - sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code."); - sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits."); - sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline."); - sb.AppendLine(" - If tests fail that passed before, fix immediately."); - sb.AppendLine(" - If build fails, analyze error output and correct."); - sb.AppendLine(" - Re-read every edited file and verify impacted callers/references before finishing."); - sb.AppendLine(" - Do not stop at a passing build if request coverage, edge cases, or changed call sites are still unverified."); - sb.AppendLine(" - For high-impact changes, you must verify both references/callers and build/test evidence before finishing."); - sb.AppendLine("7. GIT: Use git_tool to check status, create diff, and optionally commit."); - sb.AppendLine("8. REPORT: Summarize changes, test results, and any remaining concerns."); - - sb.AppendLine("\n## Development Environment"); - sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands."); - sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed."); - - // 패키지 저장소 정보 - sb.AppendLine("\n## Package Repositories"); - if (!string.IsNullOrEmpty(code.NexusBaseUrl)) - sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}"); - sb.AppendLine($"NuGet (.NET): {code.NugetSource}"); - sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}"); - sb.AppendLine($"Maven (Java): {code.MavenSource}"); - sb.AppendLine($"npm (JavaScript): {code.NpmSource}"); - sb.AppendLine("When adding dependencies, use these repository URLs."); - - // IDE 정보 - if (!string.IsNullOrEmpty(code.PreferredIdePath)) - sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}"); - - // 사용자 선택 개발 언어 - if (_selectedLanguage != "auto") - { - var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage }; - sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation."); - } - - // 언어별 가이드라인 - sb.AppendLine("\n## Language Guidelines"); - sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions."); - sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred."); - sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide."); - sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines."); - sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API."); - - // 코드 품질 + 안전 수칙 - sb.AppendLine("\n## Code Quality & Safety"); - sb.AppendLine("- NEVER delete or overwrite files without user confirmation."); - sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents."); - sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff)."); - sb.AppendLine("- When porting/referencing external code, do not copy verbatim. Rename and re-structure to match AX Copilot conventions."); - sb.AppendLine("- Use grep to find ALL references before renaming/removing anything."); - sb.AppendLine("- After editing, re-open the changed files and nearby callers to verify the final state, not just the patch intent."); - sb.AppendLine("- Treat verification as incomplete unless you can cite build/test or direct file-read evidence."); - sb.AppendLine("- If unsure about a change's impact, ask the user first."); - sb.AppendLine("- For large refactors, do them incrementally with build verification between steps."); - sb.AppendLine("- Use git_tool action='diff' to review your changes before committing."); - - sb.AppendLine("\n## Lint & Format"); - sb.AppendLine("After code changes, check for available linters:"); - sb.AppendLine("- Python: ruff, black, flake8, pylint"); - sb.AppendLine("- JavaScript: eslint, prettier"); - sb.AppendLine("- C#: dotnet format"); - sb.AppendLine("- C++: clang-format"); - sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect."); - - if (!string.IsNullOrEmpty(workFolder)) - sb.AppendLine($"\nCurrent work folder: {workFolder}"); - sb.AppendLine($"File permission mode: {llm.FilePermission}"); - sb.Append(BuildSubAgentDelegationSection(true)); - - // 폴더 데이터 활용 - sb.AppendLine("\nFolder Data Usage = ACTIVE."); - sb.AppendLine("Prefer grep/glob + targeted file_read for narrow requests."); - sb.AppendLine("Use folder_map only when structure is unclear or the request is repo-wide."); - sb.AppendLine("Read only files that are relevant to the current question. Avoid broad codebase sweeps without evidence."); - - // 프리셋 시스템 프롬프트 - lock (_convLock) - { - if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd) - sb.AppendLine("\n" + sysCmd); - } - - // 프로젝트 문맥 파일 (AGENTS.md) 주입 - sb.Append(LoadProjectContext(workFolder)); - - // 프로젝트 규칙 (.ax/rules/) 자동 주입 - sb.Append(BuildProjectRulesSection(workFolder)); - - // 에이전트 메모리 주입 - sb.Append(BuildMemorySection(workFolder)); - - // 피드백 학습 컨텍스트 주입 - sb.Append(BuildFeedbackContext()); - - return sb.ToString(); - } - - private static string BuildSubAgentDelegationSection(bool codeMode) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine("\n## Sub-Agent Delegation"); - sb.AppendLine("Use spawn_agent only for bounded side investigations that can run in parallel with your main work."); - sb.AppendLine("Good delegation targets: impact analysis, reference/caller search, test-file discovery, diff review, and bug root-cause investigation."); - sb.AppendLine("Do not delegate the final editing decision, the final report, or blocking work that you must inspect immediately yourself."); - sb.AppendLine("When spawning a sub-agent, give a concrete task with the exact question, likely file/module scope, and the output shape you want."); - sb.AppendLine("Expected sub-agent result shape: conclusion, files checked, key evidence, recommended next action, risks/unknowns."); - sb.AppendLine("Use wait_agents only when you are ready to integrate the result. Keep doing useful local work while the sub-agent runs."); - if (codeMode) - { - sb.AppendLine("For code tasks, prefer delegating read-only investigations such as caller mapping, related test discovery, or build/test failure triage."); - sb.AppendLine("For high-impact code changes, delegate at least one focused investigation for callers/references or related tests before finalizing."); - } - else - { - sb.AppendLine("For cowork tasks, prefer delegating fact gathering, source cross-checking, or evidence collection while the main agent continues planning."); - } - - return sb.ToString(); - } - - /// 프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다. - private string BuildProjectRulesSection(string? workFolder) - { - if (string.IsNullOrEmpty(workFolder)) return ""; - if (!_settings.Settings.Llm.EnableProjectRules) return ""; - - try - { - var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder); - if (rules.Count == 0) return ""; - - // 컨텍스트별 필터링: Cowork=document, Code=always (기본) - var when = _activeTab == "Code" ? "always" : "always"; - var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when); - return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered); - } - catch - { - return ""; - } - } - - /// 에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다. - private string BuildMemorySection(string? workFolder) - { - if (!_settings.Settings.Llm.EnableAgentMemory) return ""; - - var app = System.Windows.Application.Current as App; - var memService = app?.MemoryService; - if (memService == null) return ""; - - // 메모리를 로드 (작업 폴더 변경 시 재로드) - memService.Load(workFolder ?? ""); - - var all = memService.All; - var layeredDocs = memService.InstructionDocuments; - if (all.Count == 0 && layeredDocs.Count == 0) return ""; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("\n## 메모리 계층"); - sb.AppendLine("다음 메모리는 claude-code와 비슷하게 관리형 → 사용자 → 프로젝트 → 로컬 순서로 조립됩니다."); - sb.AppendLine("현재 작업 디렉토리에 가까운 메모리가 더 높은 우선순위를 가집니다.\n"); - - const int maxLayeredDocs = 8; - const int maxDocChars = 1800; - foreach (var doc in layeredDocs.Take(maxLayeredDocs)) - { - sb.AppendLine($"[{doc.Label}] {doc.Path}"); - var text = doc.Content; - if (text.Length > maxDocChars) - text = text[..maxDocChars] + "\n...(생략)"; - sb.AppendLine(text); - sb.AppendLine(); - } - - if (all.Count > 0) - { - sb.AppendLine("## 학습 메모리 (이전 대화에서 학습한 내용)"); - sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요."); - sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요."); - sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n"); - - foreach (var group in all.GroupBy(e => e.Type)) - { - var label = group.Key switch - { - "rule" => "프로젝트 규칙", - "preference" => "사용자 선호", - "fact" => "프로젝트 사실", - "correction" => "이전 교정", - _ => group.Key, - }; - sb.AppendLine($"[{label}]"); - foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15)) - sb.AppendLine($"- {e.Content}"); - sb.AppendLine(); - } - } - - return sb.ToString(); - } - /// 워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다. private void OpenWorkflowAnalyzerIfEnabled() { @@ -6663,76 +5659,12 @@ public partial class ChatWindow : Window TouchLiveAgentProgressHints(); var eventTab = runTab; - // ── 1단계: 경량 UI 피드백만 (UI 스레드) ────────────────────────────── - if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + // ── 1단계: 즉시 처리할 최소 UI 신호만 유지 ───────────────────────────── + if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase) + && evt.Type is AgentEventType.Complete or AgentEventType.Error) { - switch (evt.Type) - { - case AgentEventType.ToolCall when !string.IsNullOrWhiteSpace(evt.ToolName): - { - var (msg, icon, category) = GetStatusInfoForTool(evt.ToolName); - bool categoryChanged = category != _currentSubItemCategory; - if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible) - PulseDotStatusText.Text = msg + "..."; - if (categoryChanged) ClearStatusSubItems(); - _currentSubItemCategory = category; - - string? subItemText = null; - if (!string.IsNullOrEmpty(evt.FilePath)) - { - var fileName = System.IO.Path.GetFileName(evt.FilePath); - var suffix = GetToolSubItemSuffix(evt.ToolName); - subItemText = $"{fileName} {suffix}"; - AddStatusSubItem(subItemText, category); - } - else if (!string.IsNullOrEmpty(evt.Summary)) - { - subItemText = evt.Summary; - AddStatusSubItem(subItemText, category); - } - UpdateAgentLiveCard(msg + "...", subItemText, category, categoryChanged); - break; - } - case AgentEventType.ToolResult when !string.IsNullOrWhiteSpace(evt.ToolName): - { - var resultMsg = GetToolResultMessage(evt.ToolName) + "..."; - if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible) - PulseDotStatusText.Text = resultMsg; - UpdateAgentLiveCard(resultMsg); - break; - } - case AgentEventType.Thinking when !string.IsNullOrWhiteSpace(evt.Summary): - if (evt.Summary.Contains("LLM에 요청")) - { - UpdatePulseDotsText("응답 생성 중...", clearSubItems: true); - UpdateAgentLiveCard("응답 생성 중...", clearSubItems: true); - } - else if (evt.Summary.Contains("압축")) - { - UpdatePulseDotsText("컨텍스트 정리 중...", clearSubItems: true); - AddStatusSubItem("대화 히스토리 최적화 중"); - UpdateAgentLiveCard("컨텍스트 정리 중...", "대화 히스토리 최적화 중", clearSubItems: true); - } - else if (evt.Summary.Contains("검증")) - { - UpdatePulseDotsText("결과물 검증 중...", clearSubItems: true); - UpdateAgentLiveCard("결과물 검증 중...", clearSubItems: true); - } - else if (!string.IsNullOrWhiteSpace(evt.Summary)) - { - UpdatePulseDotsText("생각하는 중...", clearSubItems: true); - var hint = evt.Summary.Length < 60 ? evt.Summary : null; - if (hint != null) AddStatusSubItem(hint); - UpdateAgentLiveCard("생각하는 중...", hint, clearSubItems: true); - } - break; - case AgentEventType.Complete: - HideStreamingStatusBar(); - break; - case AgentEventType.Error: - HideStreamingStatusBar(); - break; - } + HideStreamingStatusBar(); + FlushPendingAgentUiEvent(); } // 현재 실행 중인 스텝 추적 (통합 진행 카드용) @@ -6809,3728 +5741,6 @@ public partial class ChatWindow : Window HideStreamingStatusBar(); } - // ─── Claude 스타일 펄스 닷 + 단계 표시 (입력창 위) ────────────────────────── - - private System.Windows.Media.Animation.Storyboard? _pulseDotStoryboard; - private System.Windows.Media.Animation.Storyboard? _statusDiamondStoryboard; - private readonly System.Collections.Generic.List _activeSubItems = new(); - private string? _currentSubItemCategory; // 현재 세부 항목 카테고리 (변경 시 초기화) - private const int MaxStatusSubItems = 6; - - // ShowStreamingStatusBar → 펄스 닷 바로 위임 (플로팅 상태 바 표시 안 함) - private void ShowStreamingStatusBar(string message, string? iconCode = null) - => ShowPulseDots(message, iconCode); - - private void HideStreamingStatusBar() - => HidePulseDots(); - - private void UpdateStreamingStatusBar(string message, string? iconCode = null) - => UpdatePulseDotsText(message, iconCode); - - // ─── 입력창 위 펄스 닷 애니메이션 ────────────────────────────────────────── - - private void ShowPulseDots(string? message = null, string? iconCode = null, string? detail = null) - { - if (PulseDotBar == null) return; - if (PulseDotStatusText != null) - PulseDotStatusText.Text = message ?? "생각하는 중..."; - ClearStatusSubItems(); - PulseDotBar.Visibility = Visibility.Visible; - StartStatusDiamondAnimation(); - if (_pulseDotStoryboard != null) return; // 이미 실행 중 - - var sb = new System.Windows.Media.Animation.Storyboard(); - var dots = new System.Windows.Shapes.Ellipse[] { PulseDot1, PulseDot2, PulseDot3 }; - const double cycleSecs = 1.2; - - for (int i = 0; i < dots.Length; i++) - { - var animX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames - { - BeginTime = TimeSpan.FromSeconds(i * 0.2), - RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, - Duration = new System.Windows.Duration(TimeSpan.FromSeconds(cycleSecs)), - }; - animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( - 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.Zero))); - animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( - 1.0, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.4))) - { - EasingFunction = new System.Windows.Media.Animation.SineEase - { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } - }); - animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( - 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.8))) - { - EasingFunction = new System.Windows.Media.Animation.SineEase - { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } - }); - animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( - 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs)))); - - System.Windows.Media.Animation.Storyboard.SetTarget(animX, dots[i]); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(animX, - new PropertyPath(UIElement.OpacityProperty)); - sb.Children.Add(animX); - } - - _pulseDotStoryboard = sb; - _pulseDotStoryboard.Begin(); - } - - private void HidePulseDots() - { - if (PulseDotBar == null) return; - _pulseDotStoryboard?.Stop(); - _pulseDotStoryboard = null; - if (PulseDot1 != null) PulseDot1.Opacity = 0.3; - if (PulseDot2 != null) PulseDot2.Opacity = 0.3; - if (PulseDot3 != null) PulseDot3.Opacity = 0.3; - StopStatusDiamondAnimation(); - ClearStatusSubItems(); - _currentSubItemCategory = null; - PulseDotBar.Visibility = Visibility.Collapsed; - } - - // ─── 채팅창 내 에이전트 라이브 진행 카드 ───────────────────────────────────── - - // ─── 미니 다이아몬드 아이콘 애니메이션 ─────────────────────────────────────── - - private void StartStatusDiamondAnimation() - { - if (_statusDiamondStoryboard != null) return; - if (StatusIconScale == null || StatusPixelBlue == null) return; - - var sb = new System.Windows.Media.Animation.Storyboard(); - - // 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감) - var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames - { - RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, - Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)), - }; - var beatTimes = new (double t, double v)[] - { - (0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00), - }; - foreach (var (t, v) in beatTimes) - { - scaleAnimX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(v, - System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(t))) - { - EasingFunction = new System.Windows.Media.Animation.QuadraticEase - { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } - }); - } - System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimX, StatusIconScale); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimX, new PropertyPath("ScaleX")); - sb.Children.Add(scaleAnimX); - - var scaleAnimY = scaleAnimX.Clone(); - System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimY, StatusIconScale); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY")); - sb.Children.Add(scaleAnimY); - - // 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게) - var pixels = new System.Windows.Shapes.Rectangle?[] - { StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed }; - for (int i = 0; i < pixels.Length; i++) - { - if (pixels[i] == null) continue; - var fade = new System.Windows.Media.Animation.DoubleAnimation( - fromValue: 1.0, toValue: 0.55, - duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2))) - { - AutoReverse = true, - RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, - BeginTime = TimeSpan.FromSeconds(i * 0.30), - EasingFunction = new System.Windows.Media.Animation.SineEase - { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }, - }; - System.Windows.Media.Animation.Storyboard.SetTarget(fade, pixels[i]); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(fade, - new PropertyPath(UIElement.OpacityProperty)); - sb.Children.Add(fade); - } - - _statusDiamondStoryboard = sb; - _statusDiamondStoryboard.Begin(); - } - - private void StopStatusDiamondAnimation() - { - _statusDiamondStoryboard?.Stop(); - _statusDiamondStoryboard = null; - if (StatusIconScale != null) { StatusIconScale.ScaleX = 1; StatusIconScale.ScaleY = 1; } - if (StatusPixelBlue != null) StatusPixelBlue.Opacity = 1; - if (StatusPixelGreen1 != null) StatusPixelGreen1.Opacity = 1; - if (StatusPixelGreen2 != null) StatusPixelGreen2.Opacity = 1; - if (StatusPixelRed != null) StatusPixelRed.Opacity = 1; - } - - // ─── 세부 항목 서브텍스트 관리 ────────────────────────────────────────────── - - private void AddStatusSubItem(string text, string? category = null) - { - if (PulseDotSubItems == null) return; - if (_activeSubItems.Contains(text)) return; // 중복 방지 - - // 카테고리 변경 시 기존 항목 초기화 - if (category != null && category != _currentSubItemCategory) - { - ClearStatusSubItems(); - _currentSubItemCategory = category; - } - - _activeSubItems.Add(text); - if (_activeSubItems.Count > MaxStatusSubItems) - { - _activeSubItems.RemoveAt(0); - if (PulseDotSubItems.Children.Count > 0) - PulseDotSubItems.Children.RemoveAt(0); - } - - var secondary = TryFindResource("SecondaryText") as System.Windows.Media.Brush - ?? System.Windows.Media.Brushes.Gray; - var tb = new TextBlock - { - Text = $"› {text}", - FontSize = 10.5, - FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"), - Foreground = secondary, - Opacity = 0.60, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = 380, - Margin = new Thickness(0, 0, 0, 1), - }; - PulseDotSubItems.Children.Add(tb); - } - - private void ClearStatusSubItems() - { - _activeSubItems.Clear(); - PulseDotSubItems?.Children.Clear(); - } - - private void UpdatePulseDotsText(string message, string? iconCode = null, string? detail = null, - bool clearSubItems = false, string? subItemCategory = null) - { - if (PulseDotBar?.Visibility != Visibility.Visible) return; - if (PulseDotStatusText != null) PulseDotStatusText.Text = message; - if (clearSubItems) ClearStatusSubItems(); - if (!string.IsNullOrEmpty(detail)) - AddStatusSubItem(detail, subItemCategory); - } - - // 레거시 호환 (단일 세부 텍스트 — 현재는 서브 아이템 목록으로 대체) - private void SetPulseDotDetail(string? detail) - { - if (!string.IsNullOrEmpty(detail)) - AddStatusSubItem(detail); - else - ClearStatusSubItems(); - } - - // 도구 이름 → (상태 메시지, MDL2 아이콘 코드, 카테고리) 변환 - private static (string message, string icon, string category) GetStatusInfoForTool(string toolName) - => toolName.ToLowerInvariant() switch - { - "document_read" => ("문서 읽는 중", "\uE8A5", "read"), - "file_read" => ("파일 읽는 중", "\uE8A5", "read"), - "folder_map" => ("폴더 구조 파악 중", "\uE8B7", "folder"), - "file_write" or "html_create" or "md_create" - or "docx_create" or "xlsx_create" - or "csv_create" or "script_create" - or "pptx_create" => ("파일 작성 중", "\uE8A3", "write"), - "file_edit" => ("파일 수정 중", "\uE70F", "edit"), - "terminal" => ("명령 실행 중", "\uE756", "terminal"), - "web_search" => ("웹 검색 중", "\uE721", "search"), - "document_plan" => ("작업 계획 수립 중", "\uE8F1", "plan"), - "diff_preview" => ("변경 사항 검토 중", "\uE8A9", "diff"), - "code_run" or "run_script" => ("코드 실행 중", "\uE756", "run"), - "git_commit" or "git_push" or "git_pull" - or "git_status" or "git_diff" => ("Git 작업 중", "\uE8A7", "git"), - _ => ("작업 실행 중", "\uE8A7", "misc"), - }; - - // 도구별 서브 아이템 접미사 ("> filename.txt 읽고 분석 중") - private static string GetToolSubItemSuffix(string toolName) - => toolName.ToLowerInvariant() switch - { - "document_read" or "file_read" => "읽고 분석 중", - "folder_map" => "구조 파악 중", - "file_write" or "html_create" or "md_create" - or "docx_create" or "xlsx_create" - or "csv_create" or "script_create" - or "pptx_create" => "작성 중", - "file_edit" => "수정 중", - "terminal" => "실행 중", - "web_search" => "검색 중", - "code_run" or "run_script" => "실행 중", - "diff_preview" => "비교 중", - "git_commit" => "커밋 중", - "git_push" => "푸시 중", - "git_pull" => "풀 중", - _ => "처리 중", - }; - - // 도구 결과 수신 후 표시할 메시지 - private static string GetToolResultMessage(string toolName) - => toolName.ToLowerInvariant() switch - { - "document_read" or "file_read" => "내용 분석 중", - "folder_map" => "구조 파악 중", - "web_search" => "검색 결과 검토 중", - "file_write" or "html_create" or "md_create" - or "docx_create" or "xlsx_create" - or "csv_create" or "script_create" - or "pptx_create" => "작성 완료, 검토 중", - "terminal" or "code_run" or "run_script" => "실행 결과 분석 중", - "git_commit" or "git_push" or "git_pull" => "Git 결과 확인 중", - _ => "결과 처리 중", - }; - - // 하위 호환 (단순 문자열만 필요한 경우) - private static string GetStatusMessageForTool(string toolName) - => GetStatusInfoForTool(toolName).message + "..."; - - private void TouchLiveAgentProgressHints() - { - _lastAgentProgressEventAt = DateTime.UtcNow; - } - - private void AgentProgressHintTimer_Tick(object? sender, EventArgs e) - { - if (!_streamingTabs.Contains(_activeTab) || !GetAgentLoop(_activeTab).IsRunning) - { - StopLiveAgentProgressHints(); - return; - } - - // _streamingTabs.Contains(_activeTab)가 위에서 이미 검증됨 — _streamRunTab은 다른 탭 시작 시 덮어써질 수 있으므로 _activeTab 사용 - var runTab = _activeTab; - if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) - && !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) - { - UpdateLiveAgentProgressHint(null); - return; - } - - var idle = DateTime.UtcNow - _lastAgentProgressEventAt; - TryGetStreamingElapsed(out var elapsed); - string? summary = null; - var toolName = "agent_wait"; - - if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2)) - { - toolName = "context_compaction"; - summary = idle >= TimeSpan.FromSeconds(12) - ? "컨텍스트를 압축한 뒤 응답을 정리하는 중입니다..." - : "컨텍스트를 압축하고 있습니다..."; - UpdateStreamingStatusBar("컨텍스트 정리 중...", "\uE72C"); - } - else if (idle >= TimeSpan.FromSeconds(90)) - { - summary = "대용량 컨텍스트 처리 중입니다... (최대 3분 소요될 수 있습니다)"; - UpdateStreamingStatusBar("대용량 컨텍스트 처리 중...", "\uE895"); - } - else if (idle >= TimeSpan.FromSeconds(30)) - { - summary = "모델이 응답을 준비하는 중입니다..."; - UpdateStreamingStatusBar("모델이 응답 준비 중...", "\uE895"); - } - else if (idle >= TimeSpan.FromSeconds(12)) - { - summary = "응답을 정리하는 중입니다..."; - } - else if (idle >= TimeSpan.FromSeconds(5)) - { - summary = "생각을 정리하는 중입니다..."; - } - else if (elapsed >= TimeSpan.FromSeconds(4)) - { - summary = "작업을 진행하는 중입니다..."; - } - - UpdateLiveAgentProgressHint(summary, toolName); - } - - private void UpdateLiveAgentProgressHint(string? summary, string toolName = "") - { - var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim(); - var currentSummary = _liveAgentProgressHint?.Summary; - var currentToolName = _liveAgentProgressHint?.ToolName ?? ""; - var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L; - var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab)); - var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab)); - var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000; - var nextElapsedBucket = elapsedMs / 3000; - var currentTokenBucket = ((_liveAgentProgressHint?.InputTokens ?? 0) + (_liveAgentProgressHint?.OutputTokens ?? 0)) / 500; - var nextTokenBucket = (inputTokens + outputTokens) / 500; - if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal) - && string.Equals(currentToolName, toolName, StringComparison.Ordinal) - && currentElapsedBucket == nextElapsedBucket - && currentTokenBucket == nextTokenBucket) - return; - - _liveAgentProgressHint = normalizedSummary == null - ? null - : new AgentEvent - { - Timestamp = DateTime.Now, - RunId = _appState.AgentRun.RunId, - Type = AgentEventType.Thinking, - ToolName = toolName, - Summary = normalizedSummary, - ElapsedMs = elapsedMs, - InputTokens = (int)inputTokens, - OutputTokens = (int)outputTokens, - }; - - var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - if (MessageList != null - && string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase) - && !IsLightweightLiveProgressMode(runTab)) - ScheduleExecutionHistoryRender(autoScroll: false); - } - - private AgentEvent? GetLiveAgentProgressHint() - { - if (_liveAgentProgressHint == null) - return null; - - var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) - return null; - - return new AgentEvent - { - Timestamp = _liveAgentProgressHint.Timestamp, - RunId = _liveAgentProgressHint.RunId, - Type = _liveAgentProgressHint.Type, - ToolName = _liveAgentProgressHint.ToolName, - Summary = _liveAgentProgressHint.Summary, - FilePath = _liveAgentProgressHint.FilePath, - Success = _liveAgentProgressHint.Success, - StepCurrent = _liveAgentProgressHint.StepCurrent, - StepTotal = _liveAgentProgressHint.StepTotal, - Steps = _liveAgentProgressHint.Steps, - ElapsedMs = _liveAgentProgressHint.ElapsedMs, - InputTokens = _liveAgentProgressHint.InputTokens, - OutputTokens = _liveAgentProgressHint.OutputTokens, - ToolInput = _liveAgentProgressHint.ToolInput, - Iteration = _liveAgentProgressHint.Iteration, - }; - } - - private static bool ShouldRenderProgressEventWhenHistoryCollapsed(AgentEvent evt) - { - if (evt.Type == AgentEventType.Complete || evt.Type == AgentEventType.Error) - return true; - - if (evt.Type == AgentEventType.Thinking) - { - if (string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) - || string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) - return true; - - if (!string.IsNullOrWhiteSpace(evt.Summary)) - return true; - } - - return IsProcessFeedEvent(evt); - } - - private void OnSubAgentStatusChanged(SubAgentStatusEvent evt) - { - Dispatcher.BeginInvoke(() => - { - try - { - _appState.ApplySubAgentStatus(evt); - ScheduleTaskSummaryRefresh(); - } - catch (Exception ex) - { - LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}"); - } - }); - } - - private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab) - { - lock (_convLock) - { - var session = _appState.ChatSession; - if (session == null) - return; - - var result = _chatEngine.AppendAgentRun( - session, - _storage, - _currentConversation, - _activeTab, - targetTab, - evt, - status, - summary); - _currentConversation = result.CurrentConversation; - ScheduleConversationPersist(result.UpdatedConversation); - } - } - - private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab) - { - lock (_convLock) - { - var session = _appState.ChatSession; - if (session == null) - return; - - var result = _chatEngine.AppendExecutionEvent( - session, - _storage, - _currentConversation, - _activeTab, - targetTab, - evt); - _currentConversation = result.CurrentConversation; - ScheduleConversationPersist(result.UpdatedConversation); - } - } - - private void SyncAppStateWithCurrentConversation() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - - _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); - _appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory); - _appState.RestoreRecentTasks(conv?.ExecutionEvents); - ApplyConversationListPreferences(conv); - UpdateTaskSummaryIndicators(); - } - - private static string GetRunStatusLabel(string? status) - => status switch - { - "completed" => "완료", - "failed" => "실패", - "paused" => "일시중지", - _ => "진행 중", - }; - - private static string GetTaskStatusLabel(string? status) - => status switch - { - "completed" => "완료", - "failed" => "실패", - "blocked" => "재시도 대기", - "waiting" => "승인 대기", - "cancelled" => "중단", - _ => "진행 중", - }; - - private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false) - => GetTranscriptDisplayName(rawName, slashPrefix); - - private static bool IsTranscriptToolLikeEvent(AgentEvent evt) - => evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall - || (!string.IsNullOrWhiteSpace(evt.ToolName) - && evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied); - - private string BuildAgentEventSummaryText(AgentEvent evt, string displayName) - => GetTranscriptEventSummary(evt, displayName); - - private IEnumerable FilterTaskSummaryItems(IEnumerable tasks) - => _taskSummaryTaskFilter switch - { - "permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)), - "queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)), - "hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)), - "subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)), - "tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)), - _ => tasks, - }; - - private Border CreateTaskSummaryFilterChip(string key, string label) - { - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; - var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase); - var chip = new Border - { - Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"), - BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 0, 5, 5), - Cursor = Cursors.Hand, - Child = new TextBlock - { - Text = label, - FontSize = 10.5, - Foreground = active ? BrushFromHex("#4338CA") : secondaryText, - } - }; - chip.MouseLeftButtonUp += (_, _) => - { - _taskSummaryTaskFilter = key; - if (_taskSummaryTarget != null) - ShowTaskSummaryPopup(); - }; - return chip; - } - - private static Brush GetRunStatusBrush(string? status) - => status switch - { - "completed" => BrushFromHex("#166534"), - "failed" => BrushFromHex("#B91C1C"), - "paused" => BrushFromHex("#B45309"), - _ => BrushFromHex("#1D4ED8"), - }; - - private static string ShortRunId(string? runId) - { - if (string.IsNullOrWhiteSpace(runId)) - return "main"; - - return runId.Length <= 8 ? runId : runId[..8]; - } - - // ─── Task Decomposition UI ──────────────────────────────────────────── - - private Border? _planningCard; - private StackPanel? _planStepsPanel; - private ProgressBar? _planProgressBar; - private TextBlock? _planProgressText; - private TextBlock? _planToggleText; - - /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). - private void AddPlanningCard(AgentEvent evt) - { - var steps = evt.Steps!; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; - var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC"); - var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); - var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC"); - - var card = new Border - { - Background = hintBg, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(8, 2, 248, 5), - HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = GetMessageMaxWidth(), - }; - - var sp = new StackPanel(); - - // 헤더 - var header = new Grid { Margin = new Thickness(0, 0, 0, 4) }; - header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; - headerLeft.Children.Add(new TextBlock - { - Text = "\uE9D5", // plan icon - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 8, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center - }); - headerLeft.Children.Add(new TextBlock - { - Text = $"계획 {steps.Count}단계", - FontSize = 9, FontWeight = FontWeights.SemiBold, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 0, 0), - }); - header.Children.Add(headerLeft); - - var toggleWrap = new Border - { - Background = Brushes.Transparent, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(6, 2, 6, 2), - Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, - }; - _planToggleText = new TextBlock - { - Text = steps.Count > 0 ? "펼치기" : "", - FontSize = 8.25, - FontWeight = FontWeights.SemiBold, - Foreground = secondaryText, - }; - toggleWrap.Child = _planToggleText; - Grid.SetColumn(toggleWrap, 1); - header.Children.Add(toggleWrap); - sp.Children.Add(header); - - // 진행률 바 - var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) }; - progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - _planProgressBar = new ProgressBar - { - Minimum = 0, - Maximum = steps.Count, - Value = 0, - Height = 3, - Foreground = accentBrush, - Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"), - VerticalAlignment = VerticalAlignment.Center, - }; - // Remove the default border on ProgressBar - _planProgressBar.BorderThickness = new Thickness(0); - Grid.SetColumn(_planProgressBar, 0); - progressGrid.Children.Add(_planProgressBar); - - _planProgressText = new TextBlock - { - Text = "0%", - FontSize = 8, FontWeight = FontWeights.SemiBold, - Foreground = secondaryText, - Margin = new Thickness(5, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(_planProgressText, 1); - progressGrid.Children.Add(_planProgressText); - sp.Children.Add(progressGrid); - - // 단계 목록 - _planStepsPanel = new StackPanel - { - Visibility = Visibility.Collapsed, - }; - for (int i = 0; i < steps.Count; i++) - { - var stepRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(0, 1, 0, 0), - Tag = i, // 인덱스 저장 - }; - - stepRow.Children.Add(new TextBlock - { - Text = "○", // 빈 원 (미완료) - FontSize = 8.5, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - Tag = "status", - }); - stepRow.Children.Add(new TextBlock - { - Text = $"{i + 1}. {steps[i]}", - FontSize = 8.75, - Foreground = primaryText, - TextWrapping = TextWrapping.Wrap, - MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68), - VerticalAlignment = VerticalAlignment.Center, - }); - - _planStepsPanel.Children.Add(stepRow); - } - sp.Children.Add(_planStepsPanel); - - toggleWrap.MouseLeftButtonUp += (_, _) => - { - if (_planStepsPanel == null || _planToggleText == null) - return; - - var expanded = _planStepsPanel.Visibility == Visibility.Visible; - _planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible; - _planToggleText.Text = expanded ? "펼치기" : "접기"; - toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC"); - }; - - card.Child = sp; - _planningCard = card; - - // 페이드인 - card.Opacity = 0; - card.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - - AddTranscriptElement(card); - } - - /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. - private void AddDecisionButtons(TaskCompletionSource tcs, List options) - { - var expressionLevel = GetAgentUiExpressionLevel(); - var showDetailedCopy = expressionLevel != "simple"; - var showRichHint = expressionLevel == "rich"; - - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var accentColor = ((SolidColorBrush)accentBrush).Color; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var itemBg = TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); - var hoverBg = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - var borderBrush = TryFindResource("BorderColor") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)); - - var container = new Border - { - Margin = expressionLevel == "simple" - ? new Thickness(40, 2, 120, 6) - : new Thickness(40, 2, 80, 6), - HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = expressionLevel == "simple" ? 460 : 560, - Background = itemBg, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(12), - Padding = new Thickness(12, 10, 12, 10), - }; - - var outerStack = new StackPanel(); - - outerStack.Children.Add(new TextBlock - { - Text = "실행 계획 승인 요청", - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - }); - if (showDetailedCopy) - { - outerStack.Children.Add(new TextBlock - { - Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.", - FontSize = 11.5, - Foreground = secondaryText, - Margin = new Thickness(0, 2, 0, 8), - TextWrapping = TextWrapping.Wrap, - }); - } - - if (showDetailedCopy && options.Count > 0) - { - var optionCandidates = new List(); - foreach (var option in options) - { - if (string.IsNullOrWhiteSpace(option)) - continue; - - optionCandidates.Add(option.Trim()); - if (optionCandidates.Count >= 3) - break; - } - var optionHint = string.Join(" · ", optionCandidates); - if (!string.IsNullOrWhiteSpace(optionHint)) - { - outerStack.Children.Add(new TextBlock - { - Text = $"선택지: {optionHint}", - FontSize = 11, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 8), - TextWrapping = TextWrapping.Wrap, - }); - } - } - if (showRichHint) - { - outerStack.Children.Add(new TextBlock - { - Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.", - FontSize = 11, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 8), - TextWrapping = TextWrapping.Wrap, - }); - } - - // 버튼 행 - var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) }; - - // 승인 버튼 (강조) - var approveBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(16, 7, 16, 7), - Margin = new Thickness(0, 0, 8, 0), - Cursor = Cursors.Hand, - }; - var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; - approveSp.Children.Add(new TextBlock - { - Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, - Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - approveSp.Children.Add(new TextBlock - { - Text = "승인 후 실행", - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.White - }); - approveBtn.Child = approveSp; - ApplyMenuItemHover(approveBtn); - approveBtn.MouseLeftButtonUp += (_, _) => - { - CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); - tcs.TrySetResult(null); // null = 승인 - }; - btnRow.Children.Add(approveBtn); - - // 수정 요청 버튼 - var editBtn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Margin = new Thickness(0, 0, 8, 0), - Cursor = Cursors.Hand, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), - BorderThickness = new Thickness(1), - }; - var editSp = new StackPanel { Orientation = Orientation.Horizontal }; - editSp.Children.Add(new TextBlock - { - Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, - Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); - editBtn.Child = editSp; - ApplyMenuItemHover(editBtn); - - // 수정 요청용 텍스트 입력 패널 (초기 숨김) - var editInputPanel = new Border - { - Visibility = Visibility.Collapsed, - Background = Brushes.Transparent, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 8, 0, 0), - }; - var editInputStack = new StackPanel(); - editInputStack.Children.Add(new TextBlock - { - Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:", - FontSize = 11.5, Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 6), - }); - var editTextBox = new TextBox - { - MinHeight = 36, - MaxHeight = 100, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - FontSize = 12.5, - Background = itemBg, - Foreground = primaryText, - CaretBrush = primaryText, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), - BorderThickness = new Thickness(1), - Padding = new Thickness(8, 6, 8, 6), - }; - editInputStack.Children.Add(editTextBox); - - var submitEditBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 5, 12, 5), - Margin = new Thickness(0, 6, 0, 0), - Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, - }; - submitEditBtn.Child = new TextBlock - { - Text = "피드백 전송", - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.White - }; - ApplyHoverScaleAnimation(submitEditBtn, 1.05); - submitEditBtn.MouseLeftButtonUp += (_, _) => - { - var feedback = editTextBox.Text.Trim(); - if (string.IsNullOrEmpty(feedback)) return; - CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush); - tcs.TrySetResult(feedback); - }; - editInputStack.Children.Add(submitEditBtn); - editInputPanel.Child = editInputStack; - - editBtn.MouseLeftButtonUp += (_, _) => - { - editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible - ? Visibility.Collapsed : Visibility.Visible; - if (editInputPanel.Visibility == Visibility.Visible) - editTextBox.Focus(); - }; - btnRow.Children.Add(editBtn); - - // 취소 버튼 - var cancelBtn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Cursor = Cursors.Hand, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), - BorderThickness = new Thickness(1), - }; - var cancelSp = new StackPanel { Orientation = Orientation.Horizontal }; - cancelSp.Children.Add(new TextBlock - { - Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, - Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - cancelSp.Children.Add(new TextBlock - { - Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), - }); - cancelBtn.Child = cancelSp; - ApplyMenuItemHover(cancelBtn); - cancelBtn.MouseLeftButtonUp += (_, _) => - { - CollapseDecisionButtons(outerStack, "✕ 취소됨", - new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))); - tcs.TrySetResult("취소"); - }; - btnRow.Children.Add(cancelBtn); - - outerStack.Children.Add(btnRow); - outerStack.Children.Add(editInputPanel); - container.Child = outerStack; - - // 슬라이드 + 페이드 등장 애니메이션 - ApplyMessageEntryAnimation(container); - AddTranscriptElement(container); - ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 - - // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 - var capturedOuterStack = outerStack; - var capturedAccent = accentBrush; - _ = tcs.Task.ContinueWith(t => - { - Dispatcher.BeginInvoke(() => - { - // 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우) - if (capturedOuterStack.Children.Count <= 1) return; - - var label = t.Result == null ? "✓ 승인됨" - : t.Result == "취소" ? "✕ 취소됨" - : "✎ 수정 요청됨"; - var fg = t.Result == "취소" - ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)) - : capturedAccent; - CollapseDecisionButtons(capturedOuterStack, label, fg); - }); - }, TaskScheduler.Default); - } - - /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. - private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) - { - outerStack.Children.Clear(); - var resultLabel = new TextBlock - { - Text = resultText, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = fg, - Opacity = 0.8, - Margin = new Thickness(0, 2, 0, 2), - }; - outerStack.Children.Add(resultLabel); - } - - // ════════════════════════════════════════════════════════════ - // 후속 작업 제안 칩 (suggest_actions) - // ════════════════════════════════════════════════════════════ - - /// suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다. - private void RenderSuggestActionChips(string jsonSummary) - { - List<(string label, string command)> actions = new(); - try - { - if (jsonSummary.Contains("\"label\"")) - { - using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary); - if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) - { - foreach (var item in doc.RootElement.EnumerateArray()) - { - var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : ""; - var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label; - if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd)); - } - } - } - else - { - // 줄바꿈 형식: "1. label → command" - foreach (var line in jsonSummary.Split('\n')) - { - var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' '); - if (string.IsNullOrEmpty(trimmed)) continue; - var parts = trimmed.Split('→', ':', '—'); - if (parts.Length >= 2) - actions.Add((parts[0].Trim(), parts[1].Trim())); - else if (!string.IsNullOrEmpty(trimmed)) - actions.Add((trimmed, trimmed)); - } - } - } - catch { return; } - - if (actions.Count == 0) return; - var preview = string.Join(", ", actions.Take(3).Select(static action => action.label)); - var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : ""; - ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}"); - } - - // ════════════════════════════════════════════════════════════ - // 피드백 학습 반영 (J) - // ════════════════════════════════════════════════════════════ - - /// 최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다. - private string BuildFeedbackContext() - { - try - { - var recentConversations = _storage.LoadAllMeta() - .OrderByDescending(m => m.UpdatedAt) - .Take(20) - .ToList(); - - var likedPatterns = new List(); - var dislikedPatterns = new List(); - - foreach (var meta in recentConversations) - { - var conv = _storage.Load(meta.Id); - if (conv == null) continue; - - foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) - { - // 첫 50자로 패턴 파악 - var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? ""; - if (msg.Feedback == "like") - likedPatterns.Add(preview); - else if (msg.Feedback == "dislike") - dislikedPatterns.Add(preview); - } - } - - if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0) - return ""; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("\n[사용자 선호도 참고]"); - if (likedPatterns.Count > 0) - { - sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):"); - foreach (var p in likedPatterns.Take(5)) - sb.AppendLine($" - \"{p}...\""); - } - if (dislikedPatterns.Count > 0) - { - sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):"); - foreach (var p in dislikedPatterns.Take(5)) - sb.AppendLine($" - \"{p}...\""); - } - sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요."); - return sb.ToString(); - } - catch { return ""; } - } - - /// 진행률 바와 단계 상태를 업데이트합니다. - private void UpdateProgressBar(AgentEvent evt) - { - if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null) - return; - - var stepIdx = evt.StepCurrent - 1; // 0-based - var total = evt.StepTotal; - - // 진행률 바 업데이트 - _planProgressBar.Value = evt.StepCurrent; - var pct = (int)((double)evt.StepCurrent / total * 100); - _planProgressText.Text = $"{pct}%"; - - // 이전 단계 완료 표시 + 현재 단계 강조 - for (int i = 0; i < _planStepsPanel.Children.Count; i++) - { - if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2) - { - var statusTb = row.Children[0] as TextBlock; - var textTb = row.Children[1] as TextBlock; - if (statusTb == null || textTb == null) continue; - - if (i < stepIdx) - { - // 완료 - statusTb.Text = "●"; - statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A")); - textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")); - } - else if (i == stepIdx) - { - // 현재 진행 중 - statusTb.Text = "◉"; - statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")); - textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B")); - textTb.FontWeight = FontWeights.SemiBold; - } - else - { - // 대기 - statusTb.Text = "○"; - statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")); - textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")); - textTb.FontWeight = FontWeights.Normal; - } - } - } - } - - /// Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다. - private static UIElement BuildDiffView(string text) - { - var panel = new StackPanel - { - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")), - MaxWidth = 520, - }; - - var diffStarted = false; - foreach (var rawLine in text.Split('\n')) - { - var line = rawLine.TrimEnd('\r'); - - // diff 헤더 전의 일반 텍스트 - if (!diffStarted && !line.StartsWith("--- ")) - { - panel.Children.Add(new TextBlock - { - Text = line, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), - FontFamily = new FontFamily("Consolas"), - Margin = new Thickness(0, 0, 0, 1), - }); - continue; - } - diffStarted = true; - - string bgHex, fgHex; - if (line.StartsWith("---") || line.StartsWith("+++")) - { - bgHex = "#F3F4F6"; fgHex = "#374151"; - } - else if (line.StartsWith("@@")) - { - bgHex = "#EFF6FF"; fgHex = "#3B82F6"; - } - else if (line.StartsWith("+")) - { - bgHex = "#ECFDF5"; fgHex = "#059669"; - } - else if (line.StartsWith("-")) - { - bgHex = "#FEF2F2"; fgHex = "#DC2626"; - } - else - { - bgHex = "Transparent"; fgHex = "#6B7280"; - } - - var tb = new TextBlock - { - Text = line, - FontSize = 10.5, - FontFamily = new FontFamily("Consolas"), - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)), - Padding = new Thickness(4, 1, 4, 1), - }; - if (bgHex != "Transparent") - tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex)); - - panel.Children.Add(tb); - } - - return panel; - } - - private sealed class ReviewSignalSummary - { - public int P0 { get; init; } - public int P1 { get; init; } - public int P2 { get; init; } - public int P3 { get; init; } - public bool HasFixed { get; init; } - public bool HasUnfixed { get; init; } - public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed; - } - - private static ReviewSignalSummary ExtractReviewSignals(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return new ReviewSignalSummary(); - - var source = text!; - var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈"); - var hasFixed = ContainsWholeWord(source, "fixed") - || ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료"); - - return new ReviewSignalSummary - { - P0 = CountToken(source, "P0"), - P1 = CountToken(source, "P1"), - P2 = CountToken(source, "P2"), - P3 = CountToken(source, "P3"), - HasUnfixed = hasUnfixed, - HasFixed = hasFixed, - }; - } - - private static int CountToken(string source, string token) - { - if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token)) - return 0; - - var count = 0; - var index = 0; - while (index < source.Length) - { - var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase); - if (hit < 0) - break; - - var before = hit == 0 ? ' ' : source[hit - 1]; - var afterIndex = hit + token.Length; - var after = afterIndex >= source.Length ? ' ' : source[afterIndex]; - if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after)) - count++; - - index = hit + token.Length; - } - - return count; - } - - private static bool ContainsAny(string source, params string[] needles) - { - foreach (var needle in needles) - { - if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) - return true; - } - - return false; - } - - private static bool ContainsWholeWord(string source, string token) - { - if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token)) - return false; - - var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b"; - return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); - } - - private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary) - { - if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase)) - return true; - - if (!string.IsNullOrWhiteSpace(toolName) && - (toolName.Contains("review", StringComparison.OrdinalIgnoreCase) || - toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase))) - return true; - - if (!string.IsNullOrWhiteSpace(title) && - title.Contains("review", StringComparison.OrdinalIgnoreCase)) - return true; - - if (!string.IsNullOrWhiteSpace(summary) && - (summary.Contains("P0", StringComparison.OrdinalIgnoreCase) || - summary.Contains("P1", StringComparison.OrdinalIgnoreCase) || - summary.Contains("P2", StringComparison.OrdinalIgnoreCase) || - summary.Contains("P3", StringComparison.OrdinalIgnoreCase))) - return true; - - return false; - } - - private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex) - { - return new Border - { - Background = BrushFromHex(bgHex), - BorderBrush = BrushFromHex(borderHex), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(999), - Margin = new Thickness(0, 0, 6, 0), - Padding = new Thickness(8, 2, 8, 2), - Child = new TextBlock - { - Text = text, - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex(fgHex), - } - }; - } - - private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary) - { - if (!IsReviewContext(kind, toolName, title, summary)) - return null; - - var signals = ExtractReviewSignals(summary); - if (!signals.HasAny) - return null; - - var row = new WrapPanel - { - Margin = new Thickness(0, 6, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - - if (signals.P0 > 0) - row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5")); - if (signals.P1 > 0) - row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74")); - if (signals.P2 > 0) - row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A")); - if (signals.P3 > 0) - row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD")); - - if (signals.HasFixed) - row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC")); - if (signals.HasUnfixed) - row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5")); - - return row.Children.Count == 0 ? null : row; - } - - /// 파일 빠른 작업 버튼 패널을 생성합니다. - private StackPanel BuildFileQuickActions(string filePath) - { - var panel = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(8, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - - var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); - var accentBrush = new SolidColorBrush(accentColor); - - Border MakeBtn(string mdlIcon, string tooltip, Action action) - { - var icon = new TextBlock - { - Text = mdlIcon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 10, - Foreground = accentBrush, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }; - var btn = new Border - { - Child = icon, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(4), - Width = 22, - Height = 22, - Margin = new Thickness(0, 0, 2, 0), - Cursor = Cursors.Hand, - ToolTip = tooltip, - }; - btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; - btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - btn.MouseLeftButtonUp += (_, _) => action(); - return btn; - } - - // 프리뷰 (지원 확장자만) - var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); - if (_previewableExtensions.Contains(ext)) - { - var path1 = filePath; - panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1))); - } - - // 외부 열기 - var path2 = filePath; - panel.Children.Add(MakeBtn("\uE8A7", "열기", () => - { - try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { } - })); - - // 폴더 열기 - var path3 = filePath; - panel.Children.Add(MakeBtn("\uED25", "폴더", () => - { - try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { } - })); - - // 경로 복사 - var path4 = filePath; - panel.Children.Add(MakeBtn("\uE8C8", "복사", () => - { - try { Clipboard.SetText(path4); } catch { } - })); - - return panel; - } - - // ─── 응답 재생성 ────────────────────────────────────────────────────── - - private async Task RegenerateLastAsync() - { - if (_streamingTabs.Contains(_activeTab)) return; - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // 마지막 assistant 메시지 제거 - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.RemoveLastAssistantMessage(_activeTab, _storage); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") - { - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - } - - RenderMessages(preserveViewport: true); - AutoScrollIfNeeded(); - - // 재전송 - await SendRegenerateAsync(conv); - } - - /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다. - private void ShowRetryWithFeedbackInput() - { - if (_streamingTabs.Contains(_activeTab)) return; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var itemBg = TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - - var container = new Border - { - Margin = new Thickness(40, 4, 40, 8), - Padding = new Thickness(14, 10, 14, 10), - CornerRadius = new CornerRadius(12), - Background = itemBg, - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - - var stack = new StackPanel(); - stack.Children.Add(new TextBlock - { - Text = "어떻게 수정하면 좋을지 알려주세요:", - FontSize = 12, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 6), - }); - - var textBox = new TextBox - { - MinHeight = 38, - MaxHeight = 80, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - FontSize = 13, - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, - Foreground = primaryText, - CaretBrush = primaryText, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6, 10, 6), - }; - stack.Children.Add(textBox); - - var btnRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 8, 0, 0), - }; - - var sendBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(14, 6, 14, 6), - Cursor = Cursors.Hand, - Margin = new Thickness(6, 0, 0, 0), - }; - sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; - sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; - sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; - sendBtn.MouseLeftButtonUp += (_, _) => - { - var feedback = textBox.Text.Trim(); - if (string.IsNullOrEmpty(feedback)) return; - RemoveTranscriptElement(container); - _ = RetryWithFeedbackAsync(feedback); - }; - - var cancelBtn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 6, 12, 6), - Cursor = Cursors.Hand, - }; - cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; - cancelBtn.MouseLeftButtonUp += (_, _) => RemoveTranscriptElement(container); - - btnRow.Children.Add(cancelBtn); - btnRow.Children.Add(sendBtn); - stack.Children.Add(btnRow); - container.Child = stack; - - ApplyMessageEntryAnimation(container); - AddTranscriptElement(container); - ForceScrollToEnd(); - textBox.Focus(); - } - - /// 사용자 피드백과 함께 마지막 응답을 재생성합니다. - private async Task RetryWithFeedbackAsync(string feedback) - { - if (_streamingTabs.Contains(_activeTab)) return; - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // 마지막 assistant 메시지 제거 - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.RemoveLastAssistantMessage(_activeTab, _storage); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") - { - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - } - - // 피드백을 사용자 메시지로 추가 - var feedbackMsg = new ChatMessage - { - Role = "user", - Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요." - }; - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.AppendMessage(_activeTab, feedbackMsg, _storage); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else - { - conv.Messages.Add(feedbackMsg); - } - } - - RenderMessages(preserveViewport: true); - AutoScrollIfNeeded(); - - // 재전송 - await SendRegenerateAsync(conv); - } - - private async Task SendRegenerateAsync(ChatConversation conv) - { - var runTab = NormalizeTabName(conv.Tab); - AxAgentExecutionEngine.PreparedExecution preparedExecution; - lock (_convLock) - preparedExecution = PrepareExecutionForConversation(conv, runTab, null); - await ExecutePreparedTurnAsync( - conv, - runTab, - conv.Tab ?? _activeTab, - preparedExecution, - "에이전트 작업 중..."); - } - - /// 채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다. - private double GetMessageMaxWidth() - { - var hostWidth = _lastResponsiveComposerWidth; - if (hostWidth < 100) - hostWidth = ComposerShell?.ActualWidth ?? 0; - if (hostWidth < 100) - hostWidth = MessageList?.ActualWidth ?? 0; - if (hostWidth < 100) - hostWidth = 1120; - - var maxW = hostWidth - 44; - return Math.Clamp(maxW, 320, 760); - } - - private bool UpdateResponsiveChatLayout() - { - var viewportWidth = MessageList?.ActualWidth ?? 0; - if (viewportWidth < 200) - viewportWidth = ActualWidth; - if (viewportWidth < 200) - return false; - - // claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록, - // 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다. - var contentWidth = Math.Max(360, viewportWidth - 24); - var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960); - var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900); - - if (contentWidth < 760) - { - messageWidth = Math.Clamp(contentWidth - 10, 344, 820); - composerWidth = Math.Clamp(contentWidth - 14, 340, 780); - } - - var changed = false; - if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1) - { - _lastResponsiveMessageWidth = messageWidth; - if (MessageList != null) - MessageList.MaxWidth = messageWidth + 48; - if (EmptyState != null) - EmptyState.MaxWidth = messageWidth; - changed = true; - } - - if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1) - { - _lastResponsiveComposerWidth = composerWidth; - if (ComposerShell != null) - { - ComposerShell.Width = composerWidth; - ComposerShell.MaxWidth = composerWidth; - } - changed = true; - } - - return changed; - } - - private StackPanel CreateStreamingContainer(out TextBlock streamText) - { - var msgMaxWidth = GetMessageMaxWidth(); - var container = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Center, - Width = msgMaxWidth, - MaxWidth = msgMaxWidth, - Margin = new Thickness(0, 3, 0, 3), - Opacity = 0, - RenderTransform = new TranslateTransform(0, 10) - }; - - // 컨테이너 페이드인 + 슬라이드 업 - container.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); - ((TranslateTransform)container.RenderTransform).BeginAnimation( - TranslateTransform.YProperty, - new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) - { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); - - var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) }; - 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 aiIcon = new TextBlock - { - Text = "\uE945", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, - Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, - VerticalAlignment = VerticalAlignment.Center - }; - // AI 아이콘 펄스 애니메이션 (응답 대기 중) - aiIcon.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) - { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, - EasingFunction = new SineEase() }); - _activeAiIcon = aiIcon; - Grid.SetColumn(aiIcon, 0); - headerGrid.Children.Add(aiIcon); - - var (streamAgentName, _, _) = GetAgentIdentity(); - var aiNameTb = new TextBlock - { - Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center - }; - Grid.SetColumn(aiNameTb, 1); - headerGrid.Children.Add(aiNameTb); - - // 실시간 경과 시간 (헤더 우측) - _elapsedLabel = new TextBlock - { - Text = "0s", - FontSize = 9.5, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.5, - }; - Grid.SetColumn(_elapsedLabel, 2); - headerGrid.Children.Add(_elapsedLabel); - - container.Children.Add(headerGrid); - - var streamCard = new Border - { - Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(13, 10, 13, 10) - }; - streamText = new TextBlock - { - Text = "\u258c", - FontSize = 12.5, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextWrapping = TextWrapping.Wrap, - LineHeight = 20, - }; - streamCard.Child = streamText; - container.Children.Add(streamCard); - return container; - } - - // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── - - private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) - { - finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent; - - // 스트리밍 plaintext 카드 제거 - if (streamText.Parent is Border streamCard) - container.Children.Remove(streamCard); - else - container.Children.Remove(streamText); - - // 마크다운 렌더링 - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; - var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; - - var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); - mdPanel.Margin = new Thickness(0, 0, 0, 4); - mdPanel.Opacity = 0; - var mdCard = new Border - { - Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(13, 10, 13, 10), - Child = mdPanel, - }; - container.Children.Add(mdCard); - mdPanel.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); - - // 액션 버튼 바 + 토큰 표시 - var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var capturedContent = finalContent; - var actionBar = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(2, 2, 0, 0), - Opacity = 0 - }; - actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => - { - try { Clipboard.SetText(capturedContent); } catch { } - })); - actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); - actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); - AddLinkedFeedbackButtons(actionBar, btnColor, message); - - container.Children.Add(actionBar); - container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar); - container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar); - container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard); - - // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) - var elapsedText = TryGetStreamingElapsed(out var elapsed) - ? (elapsed.TotalSeconds < 60 - ? $"{elapsed.TotalSeconds:0.#}s" - : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s") - : "0s"; - - var usage = _llm.LastTokenUsage; - // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 - var isAgentTab = _activeTab is "Cowork" or "Code"; - var (usageService, usageModel) = _llm.GetCurrentModelInfo(); - var tabCumIn = _tabCumulativeInputTokens.GetValueOrDefault(_activeTab); - var tabCumOut = _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab); - var displayInput = isAgentTab && tabCumIn > 0 - ? (int)tabCumIn - : usage?.PromptTokens ?? 0; - var displayOutput = isAgentTab && tabCumOut > 0 - ? (int)tabCumOut - : usage?.CompletionTokens ?? 0; - - var wasPostCompactionResponse = _pendingPostCompaction; - if (displayInput > 0 || displayOutput > 0) - { - UpdateStatusTokens(displayInput, displayOutput); - Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse); - ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput); - } - string tokenText; - if (displayInput > 0 || displayOutput > 0) - tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; - else if (usage != null) - tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; - else - tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; - - var postCompactSuffix = wasPostCompactionResponse ? " · compact 직후" : ""; - var metaText = new TextBlock - { - Text = $"{elapsedText} · {tokenText}{postCompactSuffix}", - FontSize = 9.5, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 4, 0, 0), - Opacity = 0.55, - }; - container.Children.Add(metaText); - - // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 - var chips = ParseSuggestionChips(finalContent); - if (chips.Count > 0) - { - var chipPanel = new WrapPanel - { - Margin = new Thickness(0, 8, 0, 4), - HorizontalAlignment = HorizontalAlignment.Left, - }; - foreach (var (num, label) in chips) - { - var chipBorder = new Border - { - Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Margin = new Thickness(0, 0, 8, 6), - Cursor = Cursors.Hand, - RenderTransformOrigin = new Point(0.5, 0.5), - RenderTransform = new ScaleTransform(1, 1), - }; - chipBorder.Child = new TextBlock - { - Text = $"{num}. {label}", - FontSize = 12.5, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, - }; - - var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; - var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; - chipBorder.MouseEnter += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } - }; - chipBorder.MouseLeave += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } - }; - - var capturedLabel = $"{num}. {label}"; - var capturedPanel = chipPanel; - chipBorder.MouseLeftButtonDown += (_, _) => - { - // 칩 패널 제거 (1회용) - if (capturedPanel.Parent is Panel parent) - parent.Children.Remove(capturedPanel); - // 선택한 옵션을 사용자 메시지로 전송 - InputBox.Text = capturedLabel; - _ = SendMessageAsync(); - }; - chipPanel.Children.Add(chipBorder); - } - container.Children.Add(chipPanel); - } - - // 완성된 메시지 컨테이너를 GPU 비트맵으로 캐시 → 스크롤 시 레이아웃 재계산 없이 렌더링 - container.CacheMode = new System.Windows.Media.BitmapCache(1.0) - { SnapsToDevicePixels = true }; - } - - /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) - private static List<(string Num, string Label)> ParseSuggestionChips(string content) - { - var chips = new List<(string, string)>(); - if (string.IsNullOrEmpty(content)) return chips; - - var lines = content.Split('\n'); - // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) - var candidates = new List<(string, string)>(); - var lastBlockStart = -1; - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i].Trim(); - // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 - var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); - if (m.Success) - { - if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) - { - if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - else - { - // 새로운 블록 시작 - lastBlockStart = i; - candidates.Clear(); - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - } - else if (!string.IsNullOrWhiteSpace(line)) - { - // 번호 목록이 아닌 줄이 나오면 블록 리셋 - lastBlockStart = -1; - candidates.Clear(); - } - // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) - } - - // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 - if (candidates.Count >= 2 && candidates.Count <= 10) - chips.AddRange(candidates); - - return chips; - } - - /// 토큰 수를 k/m 단위로 포맷 - private static string FormatTokenCount(int count) => count switch - { - >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", - >= 1_000 => $"{count / 1_000.0:0.#}k", - _ => count.ToString(), - }; - - /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) - private static int EstimateTokenCount(string text) - { - if (string.IsNullOrEmpty(text)) return 0; - // 한국어 문자 비율에 따라 가중 - int cjk = 0; - foreach (var c in text) - if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; - double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; - double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 - return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); - } - - // ─── 생성 중지 ────────────────────────────────────────────────────── - - private void StopGeneration() - { - // 현재 활성 탭이 스트리밍 중이면 그 탭만 취소; 아니면 모두 취소 - if (_tabStreamCts.TryGetValue(_activeTab, out var activeCts)) - activeCts.Cancel(); - else - foreach (var cts in _tabStreamCts.Values) cts.Cancel(); - - // 대기열 정리: 실행 중 + 대기 중 항목 모두 제거 (중지는 "전부 멈춤"을 의미) - lock (_convLock) - { - _draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage); - _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage); - _runningDraftId = null; - } - RefreshDraftQueueUi(); - - // 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음 - StopLiveAgentProgressHints(); - RemoveAgentLiveCard(); - HideStickyProgress(); - StopRainbowGlow(); - } - - // ─── 대화 내보내기 ────────────────────────────────────────────────── - - // ─── 대화 분기 (Fork) ────────────────────────────────────────────── - - private void ForkConversation( - ChatConversation source, - int atIndex, - string? branchHint = null, - string? branchContextMessage = null, - string? branchContextRunId = null) - { - var branchCount = _storage.LoadAllMeta() - .Count(m => m.ParentId == source.Id) + 1; - var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId) - ?? new ChatConversation - { - Title = source.Title, - Tab = source.Tab, - Category = source.Category, - WorkFolder = source.WorkFolder, - SystemCommand = source.SystemCommand, - ParentId = source.Id, - BranchLabel = $"분기 {branchCount}", - BranchAtIndex = atIndex, - }; - - try - { - _storage.Save(fork); - ShowToast($"분기 생성: {fork.Title}"); - - // 분기 대화로 전환 - lock (_convLock) - { - _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork; - SyncTabConversationIdsFromSession(); - } - ChatTitle.Text = fork.Title; - RenderMessages(); - RefreshConversationList(); - } - catch (Exception ex) - { - ShowToast($"분기 실패: {ex.Message}", "\uE783"); - } - } - - // ─── 커맨드 팔레트 ───────────────────────────────────────────────── - - private void OpenCommandPalette() - { - var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; - palette.ShowDialog(); - } - - private void ExecuteCommand(string commandId) - { - switch (commandId) - { - case "tab:chat": TabChat.IsChecked = true; break; - case "tab:cowork": TabCowork.IsChecked = true; break; - case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; - case "new_conversation": StartNewConversation(); break; - case "search_conversation": ToggleMessageSearch(); break; - case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; - case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; - case "open_statistics": new StatisticsWindow().Show(); break; - case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; - case "toggle_devmode": - var llm = _settings.Settings.Llm; - llm.DevMode = !llm.DevMode; - _settings.Save(); - UpdateAnalyzerButtonVisibility(); - ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); - break; - case "open_audit_log": - try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { } - break; - case "paste_clipboard": - try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { } - break; - case "export_conversation": ExportConversation(); break; - } - } - - private void ExportConversation() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null || conv.Messages.Count == 0) return; - - var dlg = new Microsoft.Win32.SaveFileDialog - { - FileName = $"{conv.Title}", - DefaultExt = ".md", - Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" - }; - if (dlg.ShowDialog() != true) return; - - var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); - string content; - - if (ext == ".json") - { - content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }); - } - else if (dlg.FileName.EndsWith(".pdf.html")) - { - // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 - content = PdfExportService.BuildHtml(conv); - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - PdfExportService.OpenInBrowser(dlg.FileName); - ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); - return; - } - else if (ext == ".html") - { - content = ExportToHtml(conv); - } - else - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"# {conv.Title}"); - sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); - sb.AppendLine(); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var label = msg.Role == "user" ? "**사용자**" : "**AI**"; - sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); - sb.AppendLine(); - sb.AppendLine(msg.Content); - if (msg.AttachedFiles is { Count: > 0 }) - { - sb.AppendLine(); - sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); - } - sb.AppendLine(); - sb.AppendLine("---"); - sb.AppendLine(); - } - content = sb.ToString(); - } - - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - } - - private static string ExportToHtml(ChatConversation conv) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(""); - sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); - sb.AppendLine(""); - sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); - sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var cls = msg.Role == "user" ? "user" : "ai"; - var label = msg.Role == "user" ? "사용자" : "AI"; - sb.AppendLine($"
"); - sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); - sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); - sb.AppendLine("
"); - } - - sb.AppendLine(""); - return sb.ToString(); - } - - // ─── 버튼 이벤트 ────────────────────────────────────────────────────── - - private void ChatWindow_KeyDown(object sender, KeyEventArgs e) - { - var mod = Keyboard.Modifiers; - - // Ctrl 단축키 - if (mod == ModifierKeys.Control) - { - switch (e.Key) - { - case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.W: Close(); e.Handled = true; break; - case Key.E: ExportConversation(); e.Handled = true; break; - case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; - case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.F: ToggleMessageSearch(); e.Handled = true; break; - case Key.K: OpenSidebarSearch(); e.Handled = true; break; - case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; - case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; - case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; - } - } - - // Ctrl+Shift 단축키 - if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) - { - switch (e.Key) - { - case Key.C: - // 마지막 AI 응답 복사 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv != null) - { - var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); - if (lastAi != null) - try { Clipboard.SetText(lastAi.Content); } catch { } - } - e.Handled = true; - break; - case Key.R: - // 마지막 응답 재생성 - _ = RegenerateLastAsync(); - e.Handled = true; - break; - case Key.D: - // 모든 대화 삭제 - BtnDeleteAll_Click(this, new RoutedEventArgs()); - e.Handled = true; - break; - case Key.P: - // 커맨드 팔레트 - OpenCommandPalette(); - e.Handled = true; - break; - } - } - - // Escape: 검색 바 닫기 또는 스트리밍 중지 - if (e.Key == Key.Escape) - { - if (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; } - else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } - else if (_isStreaming) { StopGeneration(); e.Handled = true; } - } - - // 슬래시 명령 팝업 키 처리 - if (TryHandleSlashNavigationKey(e)) - return; - - if (PermissionPopup.IsOpen && e.Key == Key.Escape) - { - PermissionPopup.IsOpen = false; - e.Handled = true; - } - } - - private bool TryHandleSlashNavigationKey(KeyEventArgs e) - { - if (!SlashPopup.IsOpen) - return false; - - switch (e.Key) - { - case Key.Escape: - SlashPopup.IsOpen = false; - _slashPalette.SelectedIndex = -1; - e.Handled = true; - return true; - case Key.Up: - SlashPopup_ScrollByDelta(120); - e.Handled = true; - return true; - case Key.Down: - SlashPopup_ScrollByDelta(-120); - e.Handled = true; - return true; - case Key.PageUp: - SlashPopup_ScrollByDelta(600); - e.Handled = true; - return true; - case Key.PageDown: - SlashPopup_ScrollByDelta(-600); - e.Handled = true; - return true; - case Key.Home: - { - var visible = GetVisibleSlashOrderedIndices(); - _slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches); - UpdateSlashSelectionVisualState(); - EnsureSlashSelectionVisible(); - e.Handled = true; - return true; - } - case Key.End: - { - var visible = GetVisibleSlashOrderedIndices(); - _slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches); - UpdateSlashSelectionVisualState(); - EnsureSlashSelectionVisible(); - e.Handled = true; - return true; - } - case Key.Tab when _slashPalette.SelectedIndex >= 0: - case Key.Enter when _slashPalette.SelectedIndex >= 0: - ExecuteSlashSelectedItem(); - e.Handled = true; - return true; - default: - return false; - } - } - - private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); - - private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - var activeLoop = GetAgentLoop(_activeTab); - if (activeLoop.IsPaused) - { - activeLoop.Resume(); - PauseIcon.Text = "\uE769"; // 일시정지 아이콘 - BtnPause.ToolTip = "일시정지"; - } - else - { - _ = activeLoop.PauseAsync(); - PauseIcon.Text = "\uE768"; // 재생 아이콘 - BtnPause.ToolTip = "재개"; - } - } - private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); - - // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── - - private List _searchMatchIndices = new(); - private int _searchCurrentIndex = -1; - - private void ToggleMessageSearch() - { - if (MessageSearchBar.Visibility == Visibility.Visible) - CloseMessageSearch(); - else - { - MessageSearchBar.Visibility = Visibility.Visible; - SearchTextBox.Focus(); - SearchTextBox.SelectAll(); - } - } - - private void CloseMessageSearch() - { - MessageSearchBar.Visibility = Visibility.Collapsed; - SearchTextBox.Text = ""; - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - // 하이라이트 제거 - ClearSearchHighlights(); - } - - private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - var query = SearchTextBox.Text.Trim(); - if (string.IsNullOrEmpty(query)) - { - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - ClearSearchHighlights(); - return; - } - - // 현재 대화의 메시지에서 검색 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - _searchMatchIndices.Clear(); - for (int i = 0; i < conv.Messages.Count; i++) - { - if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) - _searchMatchIndices.Add(i); - } - - if (_searchMatchIndices.Count > 0) - { - _searchCurrentIndex = 0; - SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - else - { - _searchCurrentIndex = -1; - SearchResultCount.Text = "결과 없음"; - } - } - - private void SearchPrev_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchNext_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); - - private void HighlightSearchResult() - { - if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; - var msgIndex = _searchMatchIndices[_searchCurrentIndex]; - - // MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤 - // 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로 - // (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동 - if (msgIndex < GetTranscriptElementCount()) - { - var element = GetTranscriptElementAt(msgIndex) as FrameworkElement; - element?.BringIntoView(); - } - else if (GetTranscriptElementCount() > 0) - { - // 범위 밖이면 마지막 자식으로 이동 - (GetTranscriptElementAt(GetTranscriptElementCount() - 1) as FrameworkElement)?.BringIntoView(); - } - } - - private void ClearSearchHighlights() - { - // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 - } - - // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── - - private void ShowMessageContextMenu(string content, string role) - { - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; - var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); - var (popup, panel) = CreateThemedPopupMenu(); - - // 복사 - panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () => - { - try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { } - })); - - // 마크다운 복사 - panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () => - { - try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { } - })); - - // 인용하여 답장 - panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () => - { - var quote = content.Length > 200 ? content[..200] + "..." : content; - var lines = quote.Split('\n'); - var quoted = string.Join("\n", lines.Select(l => $"> {l}")); - InputBox.Text = quoted + "\n\n"; - InputBox.Focus(); - InputBox.CaretIndex = InputBox.Text.Length; - })); - - AddPopupMenuSeparator(panel, borderBrush); - - // 재생성 (AI 응답만) - if (role == "assistant") - { - panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync())); - } - - // 대화 분기 (Fork) - panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); - if (idx < 0) return; - - ForkConversation(conv, idx); - })); - - AddPopupMenuSeparator(panel, borderBrush); - - // 이후 메시지 모두 삭제 - var msgContent = content; - var msgRole = role; - panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); - if (idx < 0) return; - - var removeCount = conv.Messages.Count - idx; - if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", - "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) - return; - - conv.Messages.RemoveRange(idx, removeCount); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RenderMessages(); - ShowToast($"{removeCount}개 메시지 삭제됨"); - })); - - Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); - } - - // ─── 팁 알림 ────────────────────────────────────────────────────── - - private static readonly string[] Tips = - [ - "💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", - "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", - "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", - "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", - "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", - "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", - "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", - "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", - "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", - "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", - "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", - "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", - "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", - "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", - "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", - "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", - "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", - "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", - "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", - "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", - "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", - "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", - "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", - "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", - ]; - private int _tipIndex; - private DispatcherTimer? _tipDismissTimer; - - private void ShowRandomTip() - { - if (!_settings.Settings.Llm.ShowTips) return; - if (_activeTab != "Cowork" && _activeTab != "Code") return; - - var tip = Tips[_tipIndex % Tips.Length]; - _tipIndex++; - - // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) - ShowTip(tip); - } - - private void ShowTip(string message) - { - // 두 타이머 모두 중지 - _tipDismissTimer?.Stop(); - _toastHideTimer?.Stop(); - _toastHideTimer = null; - - ToastText.Text = message; - ToastIcon.Text = "\uE82F"; // 전구 아이콘 - ToastBorder.Visibility = Visibility.Visible; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - - var duration = _settings.Settings.Llm.TipDurationSeconds; - if (duration <= 0) - { - // duration=0: 자동 숨기기 없음 — 기본 3초로 폴백하여 영구 잔류 방지 - duration = 3; - } - - var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; - _tipDismissTimer = timer; - timer.Tick += (_, _) => - { - if (_tipDismissTimer != timer) return; - timer.Stop(); - _tipDismissTimer = null; - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - timer.Start(); - } - - // ─── 프로젝트 문맥 파일 (AGENTS.md) ────────────────────────────────── - - /// - /// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. - /// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다. - /// - private static string LoadProjectContext(string workFolder) - { - if (string.IsNullOrEmpty(workFolder)) return ""; - - // AGENTS.md 탐색 (작업 폴더 → 상위 폴더 순, 레거시 AX.md 폴백) - var searchDir = workFolder; - for (int i = 0; i < 3; i++) // 최대 3단계 상위까지 - { - if (string.IsNullOrEmpty(searchDir)) break; - var agentsPath = System.IO.Path.Combine(searchDir, "AGENTS.md"); - var legacyPath = System.IO.Path.Combine(searchDir, "AX.md"); - var filePath = System.IO.File.Exists(agentsPath) ? agentsPath : legacyPath; - if (System.IO.File.Exists(filePath)) - { - try - { - var content = System.IO.File.ReadAllText(filePath); - if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; - var sourceName = System.IO.Path.GetFileName(filePath); - return $"\n## Project Context (from {sourceName})\n{content}\n"; - } - catch { } - } - searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; - } - return ""; - } - - // ─── 부드러운 스크롤 애니메이션 (재사용 타이머) ────────────────────── - - private DispatcherTimer? _smoothScrollTimer; - private double _smoothScrollStartOffset; - private double _smoothScrollDiff; - private DateTime _smoothScrollStartTime; - - private void SmoothScrollTimer_Tick(object? sender, EventArgs e) - { - var elapsed = (DateTime.UtcNow - _smoothScrollStartTime).TotalMilliseconds; - var progress = Math.Min(elapsed / 200.0, 1.0); - var eased = 1.0 - Math.Pow(1.0 - progress, 3); - ScrollTranscriptToVerticalOffset(_smoothScrollStartOffset + _smoothScrollDiff * eased); - - if (progress >= 1.0) - _smoothScrollTimer?.Stop(); - } - - // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── - - private DispatcherTimer? _rainbowTimer; - private DateTime _rainbowStartTime; - - private bool TryGetStreamingElapsed(out TimeSpan elapsed) - { - elapsed = TimeSpan.Zero; - if (_streamStartTime.Year < 2000) - return false; - - var now = DateTime.UtcNow; - if (_streamStartTime > now.AddSeconds(1)) - return false; - - elapsed = now - _streamStartTime; - if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6)) - { - elapsed = TimeSpan.Zero; - return false; - } - - return true; - } - - private long GetStreamingElapsedMsOrZero() - => TryGetStreamingElapsed(out var elapsed) - ? Math.Max(0L, (long)elapsed.TotalMilliseconds) - : 0L; - - /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). - private void PlayRainbowGlow() - { - if (!_settings.Settings.Llm.EnableChatRainbowGlow) return; - if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지 - - _rainbowStartTime = DateTime.UtcNow; - InputGlowBorder.Visibility = Visibility.Visible; - InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 }; - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180))); - - _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; - _rainbowTimer.Tick += (_, _) => - { - var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; - var shift = (elapsed / 2000.0) % 1.0; - var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; - if (brush == null) return; - - var angle = shift * Math.PI * 2; - brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); - brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); - }; - _rainbowTimer.Start(); - } - - /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. - private void StopRainbowGlow() - { - _rainbowTimer?.Stop(); - _rainbowTimer = null; - if (InputGlowBorder.Opacity > 0 || InputGlowBorder.Visibility == Visibility.Visible) - { - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( - InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); - fadeOut.Completed += (_, _) => - { - InputGlowBorder.Opacity = 0; - InputGlowBorder.Visibility = Visibility.Collapsed; - }; - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - } - else - { - InputGlowBorder.Visibility = Visibility.Collapsed; - } - } - - // ─── 토스트 알림 ────────────────────────────────────────────────────── - - private DispatcherTimer? _toastHideTimer; - - /// ToastBorder를 즉시 페이드아웃하고 숨깁니다. - private void HideToast() - { - _toastHideTimer?.Stop(); - _toastHideTimer = null; - _tipDismissTimer?.Stop(); - _tipDismissTimer = null; - if (ToastBorder?.Visibility != Visibility.Visible) return; - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - } - - private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) - { - // 두 타이머 모두 중지 (ShowToast/ShowTip이 같은 ToastBorder를 공유) - _toastHideTimer?.Stop(); - _tipDismissTimer?.Stop(); - - ToastText.Text = message; - ToastIcon.Text = icon; - ToastBorder.Visibility = Visibility.Visible; - - // 페이드인 - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); - - // 자동 숨기기 — 타이머 인스턴스를 로컬 변수로 캡처해 필드 재할당 간섭 방지 - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; - _toastHideTimer = timer; - timer.Tick += (_, _) => - { - if (_toastHideTimer != timer) return; // 다른 ShowToast가 교체한 경우 무시 - timer.Stop(); - _toastHideTimer = null; - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - timer.Start(); - } - - /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). - private string _selectedMood = null!; // Loaded 이벤트에서 초기화 - private string _selectedLanguage = "auto"; // Code 탭 개발 언어 - private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 - - /// 하단 바를 구성합니다 (Cowork 작업 제어 중심). - private void BuildBottomBar() - { - MoodIconPanel.Children.Clear(); - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; - } - - /// Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심. - private void BuildCodeBottomBar() - { - MoodIconPanel.Children.Clear(); - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; - } - - private Border CreateWorkspaceFolderBarButton() - { - var currentFolder = GetCurrentWorkFolder(); - var label = string.IsNullOrWhiteSpace(currentFolder) - ? "워크스페이스" - : TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18); - var tooltip = string.IsNullOrWhiteSpace(currentFolder) - ? "워크스페이스 선택" - : $"워크스페이스 선택\n현재: {currentFolder}"; - return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC"); - } - - private string GetWorktreeModeLabel() - { - var folder = GetCurrentWorkFolder(); - if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) - return "로컬"; - - var root = WorktreeStateStore.ResolveRoot(folder); - var active = WorktreeStateStore.Load(root).Active; - return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase) - ? "로컬" - : "워크트리"; - } - - private List GetAvailableWorkspaceVariants(string root, string? active) - { - var variants = new List(); - if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) - return variants; - - try - { - var parent = Directory.GetParent(root)?.FullName ?? root; - var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*")); - variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*")); - } - catch - { - // ignore discovery failures - } - - if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active)) - variants.Add(active); - - return variants - .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) - .Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(path => Directory.GetLastWriteTime(path)) - .Take(8) - .ToList(); - } - - private void SwitchToWorkspace(string targetPath, string rootPath) - { - if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath)) - return; - - if (!string.IsNullOrWhiteSpace(rootPath)) - { - var state = WorktreeStateStore.Load(rootPath); - state.Active = targetPath; - WorktreeStateStore.Save(rootPath, state); - } - - SetWorkFolder(targetPath); - ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다."); - } - - private async Task CreateCurrentBranchWorktreeAsync() - { - var currentFolder = GetCurrentWorkFolder(); - if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder)) - return; - - var root = WorktreeStateStore.ResolveRoot(currentFolder); - var gitRoot = ResolveGitRoot(root); - if (!string.IsNullOrWhiteSpace(gitRoot)) - { - await CreateGitWorktreeAsync(gitRoot); - return; - } - - var copied = CreateWorkspaceCopy(root); - SwitchToWorkspace(copied, root); - } - - private async Task CreateGitWorktreeAsync(string gitRoot) - { - var gitPath = FindGitExecutablePath(); - if (string.IsNullOrWhiteSpace(gitPath)) - return; - - var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None); - var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree"; - if (string.IsNullOrWhiteSpace(branchName)) - branchName = "worktree"; - - var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-'); - if (string.IsNullOrWhiteSpace(safeBranch)) - safeBranch = "worktree"; - - var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot; - var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var suffix = DateTime.Now.ToString("MMddHHmm"); - var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}"); - var worktreeBranch = $"ax/{safeBranch}-{suffix}"; - - var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None); - if (addResult.ExitCode != 0) - { - CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - SwitchToWorkspace(worktreePath, gitRoot); - await RefreshGitBranchStatusAsync(); - } - - private string CreateWorkspaceCopy(string root) - { - var parent = Directory.GetParent(root)?.FullName ?? root; - var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}"); - CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true); - return copyPath; - } - - private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata) - { - Directory.CreateDirectory(destination); - - foreach (var file in Directory.GetFiles(source)) - { - var name = Path.GetFileName(file); - if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) - continue; - File.Copy(file, Path.Combine(destination, name), overwrite: true); - } - - foreach (var directory in Directory.GetDirectories(source)) - { - var name = Path.GetFileName(directory); - if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) - continue; - CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata); - } - } - - /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. - private void AppendLogLevelButton() - { - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "simple"; - var levelLabel = currentLevel switch - { - "debug" => "디버그", - "detailed" => "상세", - _ => "간략", - }; - var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669"); - logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); }; - try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } } - MoodIconPanel.Children.Add(logBtn); - } - - /// 실행 이력 상세도 팝업 메뉴를 표시합니다. - private void ShowLogLevelMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - - var levels = new (string Key, string Label, string Desc)[] - { - ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), - ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), - ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), - }; - - var current = _settings.Settings.Llm.AgentLogLevel ?? "simple"; - - foreach (var (key, label, desc) in levels) - { - var isActive = current == key; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 13, - Foreground = isActive ? accentBrush : primaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = desc, - FontSize = 10, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - var item = new Border - { - Child = sp, - Padding = new Thickness(12, 8, 12, 8), - CornerRadius = new CornerRadius(6), - Background = Brushes.Transparent, - Cursor = Cursors.Hand, - }; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; - item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; - item.MouseLeftButtonUp += (_, _) => - { - _settings.Settings.Llm.AgentLogLevel = key; - _settings.Save(); - FormatMenuPopup.IsOpen = false; - if (_activeTab == "Cowork") BuildBottomBar(); - else if (_activeTab == "Code") BuildCodeBottomBar(); - }; - FormatMenuItems.Children.Add(item); - } - - try - { - var target = FindName("BtnLogLevelMenu") as UIElement; - if (target != null) FormatMenuPopup.PlacementTarget = target; - } - catch { } - FormatMenuPopup.IsOpen = true; - } - - private void ShowLanguageMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - - var languages = new (string Key, string Label, string Icon)[] - { - ("auto", "자동 감지", "🔧"), - ("python", "Python", "🐍"), - ("java", "Java", "☕"), - ("csharp", "C# (.NET)", "🔷"), - ("cpp", "C/C++", "⚙"), - ("javascript", "JavaScript / Vue", "🌐"), - }; - - foreach (var (key, label, icon) in languages) - { - var isActive = _selectedLanguage == key; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) }); - sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal }); - - var itemBorder = new Border - { - Child = sp, Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, - Padding = new Thickness(8, 7, 12, 7), - }; - ApplyMenuItemHover(itemBorder); - - var capturedKey = key; - itemBorder.MouseLeftButtonUp += (_, _) => - { - FormatMenuPopup.IsOpen = false; - _selectedLanguage = capturedKey; - BuildCodeBottomBar(); - }; - FormatMenuItems.Children.Add(itemBorder); - } - - if (FindName("BtnLangMenu") is UIElement langTarget) - FormatMenuPopup.PlacementTarget = langTarget; - FormatMenuPopup.IsOpen = true; - } - - /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일) - private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null) - { - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB"); - var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); - var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - - if (mdlIcon != null) - { - sp.Children.Add(new TextBlock - { - Text = mdlIcon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12, - Foreground = iconColor, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - } - - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 12, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - var chip = new Border - { - Child = sp, - Background = Brushes.Transparent, - BorderBrush = borderColor, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(999), - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 4, 0), - Cursor = Cursors.Hand, - ToolTip = tooltip, - }; - chip.MouseEnter += (_, _) => chip.Background = hoverBackground; - chip.MouseLeave += (_, _) => chip.Background = Brushes.Transparent; - return chip; - } - - - private static string GetFormatLabel(string key) => key switch - { - "xlsx" => "Excel", - "html" => "HTML 보고서", - "docx" => "Word", - "md" => "Markdown", - "csv" => "CSV", - _ => "AI 자동", - }; - - /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다. - private (string Name, string Symbol, string Color) GetAgentIdentity() - { - string? category = null; - lock (_convLock) - { - category = _currentConversation?.Category; - } - - return category switch - { - // Cowork 프리셋 카테고리 - "보고서" => ("보고서 에이전트", "◆", "#3B82F6"), - "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"), - "문서" => ("문서 작성 에이전트", "◆", "#6366F1"), - "논문" => ("논문 분석 에이전트", "◆", "#6366F1"), - "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"), - "자동화" => ("자동화 에이전트", "◆", "#EF4444"), - // Code 프리셋 카테고리 - "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"), - "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"), - "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"), - "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"), - "테스트" => ("테스트 에이전트", "◆", "#F59E0B"), - // Chat 카테고리 - "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"), - "시스템" => ("시스템 에이전트", "◆", "#64748B"), - "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"), - "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"), - "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"), - "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"), - "제조기술" => ("제조기술 에이전트", "◆", "#F97316"), - "재무" => ("재무 분석 에이전트", "◆", "#6366F1"), - _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"), - _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"), - _ => ("AX 에이전트", "◆", "#4B5EFC"), - }; - } - - /// 포맷 선택 팝업 메뉴를 표시합니다. - private void ShowFormatMenu() - { - FormatMenuItems.Children.Clear(); - - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto"; - - var formats = new (string Key, string Label, string Icon, string Color)[] - { - ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"), - ("xlsx", "Excel", "\uE9F9", "#217346"), - ("html", "HTML 보고서", "\uE12B", "#E44D26"), - ("docx", "Word", "\uE8A5", "#2B579A"), - ("md", "Markdown", "\uE943", "#6B7280"), - ("csv", "CSV", "\uE9D9", "#10B981"), - }; - - foreach (var (key, label, icon, color) in formats) - { - var isActive = key == currentFormat; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 13, - Foreground = BrushFromHex(color), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 13, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - var itemBorder = new Border - { - Child = sp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(8, 7, 12, 7), - }; - ApplyMenuItemHover(itemBorder); - - var capturedKey = key; - itemBorder.MouseLeftButtonUp += (_, _) => - { - FormatMenuPopup.IsOpen = false; - _settings.Settings.Llm.DefaultOutputFormat = capturedKey; - _settings.Save(); - RefreshOverlaySettingsPanel(); - BuildBottomBar(); - }; - - FormatMenuItems.Children.Add(itemBorder); - } - - // PlacementTarget을 동적 등록된 버튼으로 설정 - if (FormatMenuPopup.PlacementTarget == null && FindName("BtnFormatMenu") is UIElement formatTarget) - FormatMenuPopup.PlacementTarget = formatTarget; - FormatMenuPopup.IsOpen = true; - } - - private void BtnOverlayDefaultOutputFormat_Click(object sender, RoutedEventArgs e) - { - if (sender is UIElement element) - FormatMenuPopup.PlacementTarget = element; - ShowFormatMenu(); - } - - /// 디자인 무드 선택 팝업 메뉴를 표시합니다. - private void ShowMoodMenu() - { - MoodMenuItems.Children.Clear(); - - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - - // 2열 갤러리 그리드 - var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; - - foreach (var mood in TemplateService.AllMoods) - { - var isActive = _selectedMood == mood.Key; - var isCustom = _settings.Settings.Llm.CustomMoods.Any(cm => cm.Key == mood.Key); - var colors = TemplateService.GetMoodColors(mood.Key); - - // 미니 프리뷰 카드 - var previewCard = new Border - { - Width = 160, Height = 80, - CornerRadius = new CornerRadius(6), - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)), - BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)), - BorderThickness = new Thickness(isActive ? 2 : 1), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(2), - }; - - var previewContent = new StackPanel(); - // 헤딩 라인 - previewContent.Children.Add(new Border - { - Width = 60, Height = 6, CornerRadius = new CornerRadius(2), - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.PrimaryText)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 4), - }); - // 악센트 라인 - previewContent.Children.Add(new Border - { - Width = 40, Height = 3, CornerRadius = new CornerRadius(1), - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Accent)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 6), - }); - // 텍스트 라인들 - for (int i = 0; i < 3; i++) - { - previewContent.Children.Add(new Border - { - Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1), - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.SecondaryText)) { Opacity = 0.5 }, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 3), - }); - } - // 미니 카드 영역 - var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) }; - for (int i = 0; i < 2; i++) - { - cardRow.Children.Add(new Border - { - Width = 28, Height = 14, CornerRadius = new CornerRadius(2), - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)), - BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)), - BorderThickness = new Thickness(0.5), - Margin = new Thickness(0, 0, 4, 0), - }); - } - previewContent.Children.Add(cardRow); - previewCard.Child = previewContent; - - // 무드 라벨 - var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) }; - var labelRow = new StackPanel { Orientation = Orientation.Horizontal }; - labelRow.Children.Add(new TextBlock - { - Text = mood.Icon, FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - labelRow.Children.Add(new TextBlock - { - Text = mood.Label, FontSize = 11.5, - Foreground = primaryText, - FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, - VerticalAlignment = VerticalAlignment.Center, - }); - if (isActive) - { - labelRow.Children.Add(new TextBlock - { - Text = " ✓", FontSize = 11, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - }); - } - labelPanel.Children.Add(labelRow); - - // 전체 카드 래퍼 - var cardWrapper = new Border - { - CornerRadius = new CornerRadius(8), - Background = Brushes.Transparent, - Cursor = Cursors.Hand, - Padding = new Thickness(4), - Margin = new Thickness(2), - }; - var wrapperContent = new StackPanel(); - wrapperContent.Children.Add(previewCard); - wrapperContent.Children.Add(labelPanel); - cardWrapper.Child = wrapperContent; - - // 호버 - cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; - cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - - var capturedMood = mood; - cardWrapper.MouseLeftButtonUp += (_, _) => - { - MoodMenuPopup.IsOpen = false; - _selectedMood = capturedMood.Key; - _settings.Settings.Llm.DefaultMood = capturedMood.Key; - _settings.Save(); - SaveConversationSettings(); - RefreshOverlaySettingsPanel(); - BuildBottomBar(); - }; - - // 커스텀 무드: 우클릭 - if (isCustom) - { - cardWrapper.MouseRightButtonUp += (s, e) => - { - e.Handled = true; - MoodMenuPopup.IsOpen = false; - ShowCustomMoodContextMenu(s as Border, capturedMood.Key); - }; - } - - grid.Children.Add(cardWrapper); - } - - MoodMenuItems.Children.Add(grid); - - // ── 구분선 + 추가 버튼 ── - MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle - { - Height = 1, - Fill = borderBrush, - Margin = new Thickness(8, 4, 8, 4), - Opacity = 0.4, - }); - - var addSp = new StackPanel { Orientation = Orientation.Horizontal }; - addSp.Children.Add(new TextBlock - { - Text = "\uE710", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 13, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 8, 0), - }); - addSp.Children.Add(new TextBlock - { - Text = "커스텀 무드 추가", - FontSize = 13, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var addBorder = new Border - { - Child = addSp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(8, 6, 12, 6), - }; - ApplyMenuItemHover(addBorder); - addBorder.MouseLeftButtonUp += (_, _) => - { - MoodMenuPopup.IsOpen = false; - ShowCustomMoodDialog(); - }; - MoodMenuItems.Children.Add(addBorder); - - if (MoodMenuPopup.PlacementTarget == null && FindName("BtnMoodMenu") is UIElement moodTarget) - MoodMenuPopup.PlacementTarget = moodTarget; - MoodMenuPopup.IsOpen = true; - } - - private void BtnOverlayDefaultMood_Click(object sender, RoutedEventArgs e) - { - if (sender is UIElement element) - MoodMenuPopup.PlacementTarget = element; - ShowMoodMenu(); - } - - /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. - private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null) - { - bool isEdit = existing != null; - var dlg = new CustomMoodDialog( - existingKey: existing?.Key ?? "", - existingLabel: existing?.Label ?? "", - existingIcon: existing?.Icon ?? "🎯", - existingDesc: existing?.Description ?? "", - existingCss: existing?.Css ?? "") - { - Owner = this, - }; - - if (dlg.ShowDialog() == true) - { - if (isEdit) - { - existing!.Label = dlg.MoodLabel; - existing.Icon = dlg.MoodIcon; - existing.Description = dlg.MoodDescription; - existing.Css = dlg.MoodCss; - } - else - { - _settings.Settings.Llm.CustomMoods.Add(new Models.CustomMoodEntry - { - Key = dlg.MoodKey, - Label = dlg.MoodLabel, - Icon = dlg.MoodIcon, - Description = dlg.MoodDescription, - Css = dlg.MoodCss, - }); - } - _settings.Save(); - TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); - BuildBottomBar(); - } - } - - /// 커스텀 무드 우클릭 컨텍스트 메뉴. - private void ShowCustomMoodContextMenu(Border? anchor, string moodKey) - { - if (anchor == null) return; - - var popup = new System.Windows.Controls.Primitives.Popup - { - PlacementTarget = anchor, - Placement = System.Windows.Controls.Primitives.PlacementMode.Right, - StaysOpen = false, AllowsTransparency = true, - }; - - var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - - var menuBorder = new Border - { - Background = menuBg, - CornerRadius = new CornerRadius(10), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(4), - MinWidth = 120, - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, - }, - }; - - var stack = new StackPanel(); - - var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText); - editItem.MouseLeftButtonDown += (_, _) => - { - popup.IsOpen = false; - var entry = _settings.Settings.Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey); - if (entry != null) ShowCustomMoodDialog(entry); - }; - stack.Children.Add(editItem); - - var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText); - deleteItem.MouseLeftButtonDown += (_, _) => - { - popup.IsOpen = false; - var result = CustomMessageBox.Show( - $"이 디자인 무드를 삭제하시겠습니까?", - "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - _settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); - if (_selectedMood == moodKey) _selectedMood = "modern"; - _settings.Save(); - TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); - BuildBottomBar(); - } - }; - stack.Children.Add(deleteItem); - - menuBorder.Child = stack; - popup.Child = menuBorder; - popup.IsOpen = true; - } - - - private string? _promptCardPlaceholder; - - private void ShowPlaceholder() - { - RefreshInputWatermarkText(); - InputWatermark.Visibility = Visibility.Visible; - InputBox.Text = ""; - InputBox.Focus(); - } - - private void UpdateWatermarkVisibility() - { - // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) - if (_slashPalette.ActiveCommand != null) - { - InputWatermark.Visibility = Visibility.Collapsed; - return; - } - - RefreshInputWatermarkText(); - - if (string.IsNullOrEmpty(InputBox.Text)) - InputWatermark.Visibility = Visibility.Visible; - else - InputWatermark.Visibility = Visibility.Collapsed; - } - - private void ClearPromptCardPlaceholder() - { - _promptCardPlaceholder = null; - RefreshInputWatermarkText(); - UpdateWatermarkVisibility(); - } - - private void BtnSettings_Click(object sender, RoutedEventArgs e) - { - OpenAgentSettingsWindow(); - } - // ─── 프롬프트 템플릿 팝업 ──────────────────────────────────────────── private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e) @@ -10736,3661 +5946,6 @@ public partial class ChatWindow : Window _settings.Save(); } - // ─── 모델 전환 ────────────────────────────────────────────────────── - - // Gemini/Claude 사전 정의 모델 목록 - private static readonly (string Id, string Label)[] GeminiModels = - { - ("gemini-2.5-pro", "Gemini 2.5 Pro"), - ("gemini-2.5-flash", "Gemini 2.5 Flash"), - ("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), - ("gemini-2.0-flash", "Gemini 2.0 Flash"), - ("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"), - }; - private static readonly (string Id, string Label)[] ClaudeModels = - { - (string.Concat("cl", "aude-opus-4-6"), "Claude Opus 4.6"), - (string.Concat("cl", "aude-sonnet-4-6"), "Claude Sonnet 4.6"), - (string.Concat("cl", "aude-haiku-4-5-20251001"), "Claude Haiku 4.5"), - (string.Concat("cl", "aude-sonnet-4-5-20250929"), "Claude Sonnet 4.5"), - (string.Concat("cl", "aude-opus-4-20250514"), "Claude Opus 4"), - }; - - /// 현재 선택된 모델의 표시명을 반환합니다. - private string GetCurrentModelDisplayName() - { - var llm = _settings.Settings.Llm; - var service = llm.Service.ToLowerInvariant(); - - if (service is "ollama" or "vllm") - { - // 등록 모델에서 별칭 찾기 - var registered = llm.RegisteredModels - .FirstOrDefault(rm => rm.EncryptedModelName == llm.Model); - if (registered != null) return registered.Alias; - return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : "••••"; - } - - if (service == "gemini") - { - var m = GeminiModels.FirstOrDefault(g => g.Id == llm.Model); - return m.Label ?? llm.Model; - } - if (service is "sigmoid" or "cl" + "aude") - { - var m = ClaudeModels.FirstOrDefault(c => c.Id == llm.Model); - return m.Label ?? llm.Model; - } - return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : llm.Model; - } - - private void UpdateModelLabel() - { - var service = _settings.Settings.Llm.Service.ToLowerInvariant(); - var serviceLabel = service switch - { - "gemini" => "Gemini", - "sigmoid" or "cl" + "aude" => "Claude", - "vllm" => "vLLM", - _ => "Ollama", - }; - var model = GetCurrentModelDisplayName(); - const int maxLen = 24; - if (model.Length > maxLen) - model = model[..(maxLen - 1)] + "…"; - ModelLabel.Text = string.IsNullOrWhiteSpace(model) ? serviceLabel : model; - if (BtnModelSelector != null) - BtnModelSelector.ToolTip = $"현재 모델: {GetCurrentModelDisplayName()}\n서비스: {serviceLabel}"; - } - - private static string NextReasoning(string current) => (current ?? "normal").ToLowerInvariant() switch - { - "minimal" => "normal", - "normal" => "detailed", - _ => "minimal", - }; - private static string ReasoningLabel(string value) => (value ?? "normal").ToLowerInvariant() switch - { - "minimal" => "낮음", - "detailed" => "높음", - _ => "중간", - }; - private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch - { - "deny" => "Default", - "default" => "AcceptEdits", - "acceptedits" => "Plan", - "plan" => "BypassPermissions", - "bypasspermissions" => "Default", - _ => "Default", - }; - private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch - { - "gemini" => "Gemini", - "sigmoid" or "cl" + "aude" => "Claude", - "vllm" => "vLLM", - _ => "Ollama", - }; - - private List<(string Id, string Label)> GetModelCandidates(string service) - { - var llm = _settings.Settings.Llm; - var normalized = (service ?? "ollama").ToLowerInvariant(); - - if (normalized is "ollama" or "vllm") - { - return llm.RegisteredModels - .Where(rm => string.Equals(rm.Service, normalized, StringComparison.OrdinalIgnoreCase)) - .Select(rm => (rm.EncryptedModelName, rm.Alias)) - .ToList(); - } - - if (normalized == "gemini") - return GeminiModels.Select(m => (m.Id, m.Label)).ToList(); - if (normalized is "sigmoid" or "cl" + "aude") - return ClaudeModels.Select(m => (m.Id, m.Label)).ToList(); - - return []; - } - - private static bool SupportsOverlayRegisteredModels(string service) - => string.Equals(service, "ollama", StringComparison.OrdinalIgnoreCase) - || string.Equals(service, "vllm", StringComparison.OrdinalIgnoreCase); - - private void RefreshInlineSettingsPanel() - { - if (InlineSettingsPanel == null) return; - - var llm = _settings.Settings.Llm; - var service = (llm.Service ?? "ollama").ToLowerInvariant(); - var models = GetModelCandidates(service); - - _isInlineSettingsSyncing = true; - try - { - if (InlineServiceCardPanel != null) - InlineServiceCardPanel.Children.Clear(); - if (InlineModelListPanel != null) - InlineModelListPanel.Children.Clear(); - - CmbInlineService.Items.Clear(); - foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) - { - CmbInlineService.Items.Add(new ComboBoxItem - { - Content = ServiceLabel(svc), - Tag = svc, - }); - } - var normalizedService = service == "sigmoid" ? string.Concat("cl", "aude") : service; - CmbInlineService.SelectedIndex = Math.Max(0, new[] { "ollama", "vllm", "gemini", "claude" }.ToList().IndexOf(normalizedService)); - - BuildInlineServiceCards(normalizedService); - - CmbInlineModel.Items.Clear(); - if (models.Count == 0) - { - CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false }); - CmbInlineModel.SelectedIndex = 0; - } - else - { - foreach (var (id, label) in models) - { - CmbInlineModel.Items.Add(new ComboBoxItem - { - Content = label, - Tag = id, - }); - } - - var selectedIndex = models.FindIndex(m => m.Id == llm.Model); - CmbInlineModel.SelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; - BuildInlineModelRows(models, llm.Model); - } - - BtnInlineFastMode.Content = GetQuickActionLabel("Gemini 대기", llm.FreeTierMode ? "켜짐" : "꺼짐"); - BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel)); - BtnInlinePermission.Content = GetQuickActionLabel("권한", PermissionModeCatalog.ToDisplayLabel(llm.FilePermission)); - BtnInlineSkill.Content = $"스킬 · {(llm.EnableSkillSystem ? "On" : "Off")}"; - BtnInlineCommandBrowser.Content = "명령/스킬 브라우저"; - - var mcpTotal = llm.McpServers?.Count ?? 0; - var mcpEnabled = llm.McpServers?.Count(x => x.Enabled) ?? 0; - BtnInlineMcp.Content = $"MCP 상태 · {mcpEnabled}/{mcpTotal}"; - - ApplyQuickActionVisual(BtnInlineFastMode, llm.FreeTierMode, "#ECFDF5", "#166534"); - ApplyQuickActionVisual(BtnInlineReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8"); - ApplyQuickActionVisual(BtnInlinePermission, - !string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase), - "#FFF7ED", - "#C2410C"); - } - finally - { - _isInlineSettingsSyncing = false; - } - } - - private void BuildInlineServiceCards(string selectedService) - { - if (InlineServiceCardPanel == null) - return; - - // 테마 색상 미리 로드 — 하드코딩된 라이트 전용 색상 대신 테마 리소스 사용 - var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xEA, 0xEA, 0xF0)); - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? hintBg; - - foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) - { - var isActive = string.Equals(svc, selectedService, StringComparison.OrdinalIgnoreCase); - var card = new Border - { - Background = isActive ? hintBg : Brushes.Transparent, - BorderBrush = isActive ? accentBrush : borderColor, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(10, 6, 10, 6), - Margin = new Thickness(0, 0, 6, 6), - Cursor = Cursors.Hand, - }; - var text = new TextBlock - { - Text = ServiceLabel(svc), - FontSize = 10.5, - Foreground = isActive ? accentBrush : primaryText, - }; - card.Child = text; - var capturedService = svc; - card.MouseEnter += (_, _) => - { - if (!isActive) - card.Background = hoverBg; - }; - card.MouseLeave += (_, _) => - { - if (!isActive) - card.Background = Brushes.Transparent; - }; - card.MouseLeftButtonUp += (_, _) => - { - if (_isInlineSettingsSyncing) - return; - _settings.Settings.Llm.Service = capturedService; - _settings.Save(); - UpdateModelLabel(); - RefreshInlineSettingsPanel(); - }; - InlineServiceCardPanel.Children.Add(card); - } - } - - private void BuildInlineModelRows(List<(string Id, string Label)> models, string? selectedModel) - { - if (InlineModelListPanel == null) - return; - - // 테마 색상 미리 로드 — 하드코딩된 라이트 전용 색상 대신 테마 리소스 사용 - // (다크 테마에서 흰 배경 + 흰 텍스트로 글자가 안보이는 문제 수정) - var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xEA, 0xEA, 0xF0)); - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - - foreach (var (id, label) in models.Take(8)) - { - var isActive = string.Equals(id, selectedModel, StringComparison.OrdinalIgnoreCase); - var row = new Border - { - Background = isActive ? hintBg : Brushes.Transparent, - BorderBrush = isActive ? accentBrush : borderColor, - BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1), - Padding = new Thickness(8, 8, 8, 8), - Cursor = Cursors.Hand, - }; - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - var labelText = new TextBlock - { - Text = label, - FontSize = 11.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - }; - grid.Children.Add(labelText); - var stateText = new TextBlock - { - Text = isActive ? "사용 중" : "선택", - FontSize = 10, - Foreground = isActive ? accentBrush : secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(stateText, 1); - grid.Children.Add(stateText); - row.Child = grid; - var capturedId = id; - var capturedLabel = label; - row.MouseEnter += (_, _) => - { - row.Background = hintBg; - row.BorderBrush = isActive ? accentBrush : borderColor; - }; - row.MouseLeave += (_, _) => - { - row.Background = isActive ? hintBg : Brushes.Transparent; - row.BorderBrush = isActive ? accentBrush : borderColor; - }; - row.MouseLeftButtonUp += (_, _) => - { - if (_isInlineSettingsSyncing) - return; - _settings.Settings.Llm.Model = capturedId; - _settings.Save(); - UpdateModelLabel(); - RefreshInlineSettingsPanel(); - SetStatus($"모델 전환: {capturedLabel}", spinning: false); - }; - InlineModelListPanel.Children.Add(row); - } - } - - private void BtnModelSelector_Click(object sender, RoutedEventArgs e) - { - RefreshInlineSettingsPanel(); - InlineSettingsPanel.IsOpen = !InlineSettingsPanel.IsOpen; - - if (InlineSettingsPanel.IsOpen) - { - Dispatcher.BeginInvoke(() => - { - if (CmbInlineModel.Items.Count > 0) - CmbInlineModel.Focus(); - else - InputBox.Focus(); - }, DispatcherPriority.Input); - } - } - - private void OpenAgentSettingsWindow() - { - RefreshOverlaySettingsPanel(); - AgentSettingsOverlay.Visibility = Visibility.Visible; - InlineSettingsPanel.IsOpen = false; - SetOverlaySection("basic"); - Dispatcher.BeginInvoke(() => - { - if (OverlayNavBasic != null) - OverlayNavBasic.Focus(); - else - InputBox.Focus(); - }, DispatcherPriority.Input); - } - - public void OpenAgentSettingsFromExternal() - { - Dispatcher.BeginInvoke(() => - { - Show(); - Activate(); - OpenAgentSettingsWindow(); - }, DispatcherPriority.Input); - } - - public void RefreshFromSavedSettings() - { - Dispatcher.BeginInvoke(() => - { - ApplyAgentThemeResources(); - LoadConversationSettings(); - UpdatePermissionUI(); - UpdateDataUsageUI(); - UpdateModelLabel(); - RefreshInlineSettingsPanel(); - RefreshOverlaySettingsPanel(); - RefreshContextUsageVisual(); - UpdateTabUI(); - BuildBottomBar(); - RefreshDraftQueueUi(); - RefreshConversationList(); - if (_isStreaming && _settings.Settings.Llm.EnableChatRainbowGlow) - PlayRainbowGlow(); - else - StopRainbowGlow(); - }, DispatcherPriority.Input); - } - - private void BtnOverlaySettingsClose_Click(object sender, RoutedEventArgs e) - { - ApplyOverlaySettingsChanges(showToast: false, closeOverlay: true); - } - - private void ApplyOverlaySettingsChanges(bool showToast, bool closeOverlay) - { - var llm = _settings.Settings.Llm; - - _settings.Settings.AiEnabled = true; - llm.EnableProactiveContextCompact = ChkOverlayEnableProactiveCompact?.IsChecked == true; - llm.EnableSkillSystem = ChkOverlayEnableSkillSystem?.IsChecked == true; - llm.EnableToolHooks = ChkOverlayEnableToolHooks?.IsChecked == true; - llm.EnableHookInputMutation = ChkOverlayEnableHookInputMutation?.IsChecked == true; - llm.EnableHookPermissionUpdate = ChkOverlayEnableHookPermissionUpdate?.IsChecked == true; - llm.EnableCoworkVerification = ChkOverlayEnableCoworkVerification?.IsChecked == true; - llm.Code.EnableCodeVerification = ChkOverlayEnableCodeVerification?.IsChecked == true; - llm.Code.EnableCodeReview = ChkOverlayEnableCodeReview?.IsChecked == true; - llm.EnableImageInput = ChkOverlayEnableImageInput?.IsChecked == true; - llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true; - llm.EnableProjectRules = ChkOverlayEnableProjectRules?.IsChecked == true; - llm.EnableAgentMemory = ChkOverlayEnableAgentMemory?.IsChecked == true; - llm.Code.EnableWorktreeTools = ChkOverlayEnableWorktreeTools?.IsChecked == true; - llm.Code.EnableTeamTools = ChkOverlayEnableTeamTools?.IsChecked == true; - llm.Code.EnableCronTools = ChkOverlayEnableCronTools?.IsChecked == true; - llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer?.IsChecked == true; - llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats?.IsChecked == true; - llm.EnableAuditLog = ChkOverlayEnableAuditLog?.IsChecked == true; - llm.EnableChatRainbowGlow = ChkOverlayEnableChatRainbowGlow?.IsChecked == true; - - CommitOverlayEndpointInput(normalizeOnInvalid: true); - CommitOverlayApiKeyInput(); - CommitOverlayModelInput(normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, llm.ContextCompactTriggerPercent, 10, 95, value => llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxContextTokens, llm.MaxContextTokens, 1024, 1_000_000, value => llm.MaxContextTokens = value, normalizeOnInvalid: true); - CommitOverlayTemperatureInput(normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, llm.MaxAgentIterations, 1, 200, value => llm.MaxAgentIterations = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, llm.FreeTierDelaySeconds, 0, 60, value => llm.FreeTierDelaySeconds = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxSubAgents, llm.MaxSubAgents, 1, 10, value => llm.MaxSubAgents = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, llm.ToolHookTimeoutMs, 3000, 30000, value => llm.ToolHookTimeoutMs = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, llm.MaxFavoriteSlashCommands, 1, 30, value => llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, llm.MaxRecentSlashCommands, 5, 50, value => llm.MaxRecentSlashCommands = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, llm.PlanDiffSeverityMediumCount, 1, 999, value => llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, llm.PlanDiffSeverityHighCount, 1, 999, value => llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: true); - CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: true); - if (TxtOverlayPdfExportPath != null) - llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim(); - - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - if (closeOverlay) - AgentSettingsOverlay.Visibility = Visibility.Collapsed; - if (showToast) - ShowToast("AX Agent 설정이 저장되었습니다."); - InputBox.Focus(); - } - - private void PersistOverlaySettingsState(bool refreshOverlayDeferredInputs) - { - _settings.Save(); - _appState.LoadFromSettings(_settings); - ApplyAgentThemeResources(); - UpdatePermissionUI(); - UpdateDataUsageUI(); - SyncWorkflowVisualizerWindow(); - SaveConversationSettings(); - RefreshInlineSettingsPanel(); - UpdateModelLabel(); - UpdateTabUI(); - RefreshOverlayVisualState(refreshOverlayDeferredInputs); - } - - private void RefreshOverlayVisualState(bool loadDeferredInputs) - { - var llm = _settings.Settings.Llm; - var service = NormalizeOverlayService(llm.Service); - var models = GetModelCandidates(service); - - _isOverlaySettingsSyncing = true; - try - { - if (CmbOverlayService != null) - { - CmbOverlayService.Items.Clear(); - foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) - { - CmbOverlayService.Items.Add(new ComboBoxItem - { - Content = ServiceLabel(svc), - Tag = svc - }); - } - - CmbOverlayService.SelectedItem = CmbOverlayService.Items - .OfType() - .FirstOrDefault(i => string.Equals(i.Tag as string, service, StringComparison.OrdinalIgnoreCase)); - } - - if (CmbOverlayModel != null) - { - CmbOverlayModel.Items.Clear(); - foreach (var model in models) - { - CmbOverlayModel.Items.Add(new ComboBoxItem - { - Content = model.Label, - Tag = model.Id - }); - } - - CmbOverlayModel.SelectedItem = CmbOverlayModel.Items - .OfType() - .FirstOrDefault(i => string.Equals(i.Tag as string, llm.Model, StringComparison.OrdinalIgnoreCase)); - - if (CmbOverlayModel.SelectedItem == null && CmbOverlayModel.Items.Count > 0) - CmbOverlayModel.SelectedIndex = 0; - } - - if (OverlaySelectedServiceText != null) - OverlaySelectedServiceText.Text = ServiceLabel(service); - if (OverlaySelectedModelText != null) - OverlaySelectedModelText.Text = string.IsNullOrWhiteSpace(llm.Model) - ? "미선택" - : (models.FirstOrDefault(m => string.Equals(m.Id, llm.Model, StringComparison.OrdinalIgnoreCase)).Label ?? llm.Model); - - PopulateOverlayMoodCombo(); - - if (loadDeferredInputs) - { - if (ChkOverlayAiEnabled != null) - ChkOverlayAiEnabled.IsChecked = true; - if (TxtOverlayServiceEndpoint != null) - TxtOverlayServiceEndpoint.Text = GetOverlayServiceEndpoint(service); - if (TxtOverlayServiceApiKey != null) - TxtOverlayServiceApiKey.Text = GetOverlayServiceApiKey(service); - if (TxtOverlayContextCompactTriggerPercent != null) - TxtOverlayContextCompactTriggerPercent.Text = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95).ToString(); - if (TxtOverlayMaxContextTokens != null) - TxtOverlayMaxContextTokens.Text = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000).ToString(); - if (TxtOverlayTemperature != null) - TxtOverlayTemperature.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); - if (SldOverlayTemperature != null) - SldOverlayTemperature.Value = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1); - if (TxtOverlayTemperatureValue != null) - TxtOverlayTemperatureValue.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); - RefreshOverlayTemperatureModeButtons(); - if (TxtOverlayMaxRetryOnError != null) - TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString(); - if (SldOverlayMaxRetryOnError != null) - SldOverlayMaxRetryOnError.Value = Math.Clamp(llm.MaxRetryOnError, 0, 10); - if (TxtOverlayMaxRetryOnErrorValue != null) - TxtOverlayMaxRetryOnErrorValue.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString(); - if (TxtOverlayMaxAgentIterations != null) - TxtOverlayMaxAgentIterations.Text = Math.Clamp(llm.MaxAgentIterations, 1, 200).ToString(); - if (SldOverlayMaxAgentIterations != null) - SldOverlayMaxAgentIterations.Value = Math.Clamp(llm.MaxAgentIterations, 1, 100); - if (TxtOverlayMaxAgentIterationsValue != null) - TxtOverlayMaxAgentIterationsValue.Text = Math.Clamp(llm.MaxAgentIterations, 1, 100).ToString(); - if (TxtOverlayFreeTierDelaySeconds != null) - TxtOverlayFreeTierDelaySeconds.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString(); - if (SldOverlayFreeTierDelaySeconds != null) - SldOverlayFreeTierDelaySeconds.Value = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60); - if (TxtOverlayFreeTierDelaySecondsValue != null) - TxtOverlayFreeTierDelaySecondsValue.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString(); - if (TxtOverlayMaxSubAgents != null) - TxtOverlayMaxSubAgents.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString(); - if (SldOverlayMaxSubAgents != null) - SldOverlayMaxSubAgents.Value = Math.Clamp(llm.MaxSubAgents, 1, 10); - if (TxtOverlayMaxSubAgentsValue != null) - TxtOverlayMaxSubAgentsValue.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString(); - if (TxtOverlayToolHookTimeoutMs != null) - TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString(); - if (SldOverlayToolHookTimeoutMs != null) - SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000); - if (TxtOverlayToolHookTimeoutMsValue != null) - TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s"; - if (TxtOverlayMaxFavoriteSlashCommands != null) - TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); - if (SldOverlayMaxFavoriteSlashCommands != null) - SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30); - if (TxtOverlayMaxFavoriteSlashCommandsValue != null) - TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); - if (TxtOverlayMaxRecentSlashCommands != null) - TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); - if (SldOverlayMaxRecentSlashCommands != null) - SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50); - if (TxtOverlayMaxRecentSlashCommandsValue != null) - TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); - if (TxtOverlayPlanDiffMediumCount != null) - TxtOverlayPlanDiffMediumCount.Text = Math.Clamp(llm.PlanDiffSeverityMediumCount, 1, 999).ToString(); - if (TxtOverlayPlanDiffHighCount != null) - TxtOverlayPlanDiffHighCount.Text = Math.Clamp(llm.PlanDiffSeverityHighCount, 1, 999).ToString(); - if (TxtOverlayPlanDiffMediumRatio != null) - TxtOverlayPlanDiffMediumRatio.Text = Math.Clamp(llm.PlanDiffSeverityMediumRatioPercent, 1, 100).ToString(); - if (TxtOverlayPlanDiffHighRatio != null) - TxtOverlayPlanDiffHighRatio.Text = Math.Clamp(llm.PlanDiffSeverityHighRatioPercent, 1, 100).ToString(); - if (TxtOverlayPdfExportPath != null) - TxtOverlayPdfExportPath.Text = llm.PdfExportPath ?? ""; - if (ChkOverlayEnableProactiveCompact != null) - ChkOverlayEnableProactiveCompact.IsChecked = llm.EnableProactiveContextCompact; - if (ChkOverlayEnableSkillSystem != null) - ChkOverlayEnableSkillSystem.IsChecked = llm.EnableSkillSystem; - if (ChkOverlayEnableToolHooks != null) - ChkOverlayEnableToolHooks.IsChecked = llm.EnableToolHooks; - if (ChkOverlayEnableHookInputMutation != null) - ChkOverlayEnableHookInputMutation.IsChecked = llm.EnableHookInputMutation; - if (ChkOverlayEnableHookPermissionUpdate != null) - ChkOverlayEnableHookPermissionUpdate.IsChecked = llm.EnableHookPermissionUpdate; - if (ChkOverlayEnableCoworkVerification != null) - ChkOverlayEnableCoworkVerification.IsChecked = llm.EnableCoworkVerification; - if (ChkOverlayEnableCodeVerification != null) - ChkOverlayEnableCodeVerification.IsChecked = llm.Code.EnableCodeVerification; - if (ChkOverlayEnableCodeReview != null) - ChkOverlayEnableCodeReview.IsChecked = llm.Code.EnableCodeReview; - if (ChkOverlayEnableImageInput != null) - ChkOverlayEnableImageInput.IsChecked = llm.EnableImageInput; - if (ChkOverlayEnableParallelTools != null) - ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools; - if (ChkOverlayEnableProjectRules != null) - ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules; - if (ChkOverlayEnableAgentMemory != null) - ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory; - if (ChkOverlayEnableWorktreeTools != null) - ChkOverlayEnableWorktreeTools.IsChecked = llm.Code.EnableWorktreeTools; - if (ChkOverlayEnableTeamTools != null) - ChkOverlayEnableTeamTools.IsChecked = llm.Code.EnableTeamTools; - if (ChkOverlayEnableCronTools != null) - ChkOverlayEnableCronTools.IsChecked = llm.Code.EnableCronTools; - if (ChkOverlayWorkflowVisualizer != null) - ChkOverlayWorkflowVisualizer.IsChecked = llm.WorkflowVisualizer; - if (ChkOverlayShowTotalCallStats != null) - ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats; - if (ChkOverlayEnableAuditLog != null) - ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog; - if (ChkOverlayEnableDetailedLog != null) - ChkOverlayEnableDetailedLog.IsChecked = llm.EnableDetailedLog; - if (ChkOverlayEnableRawLlmLog != null) - ChkOverlayEnableRawLlmLog.IsChecked = llm.EnableRawLlmLog; - if (ChkOverlayEnableChatRainbowGlow != null) - ChkOverlayEnableChatRainbowGlow.IsChecked = llm.EnableChatRainbowGlow; - } - - RefreshOverlayThemeCards(); - RefreshOverlayServiceCards(); - RefreshOverlayModeButtons(); - RefreshOverlayTokenPresetCards(); - RefreshOverlayServiceFieldLabels(service); - RefreshOverlayServiceFieldVisibility(service); - BuildOverlayRegisteredModelsPanel(service); - RefreshOverlayAdvancedChoiceButtons(); - RefreshOverlayRetentionButtons(); - RefreshOverlayStorageSummary(); - } - finally - { - _isOverlaySettingsSyncing = false; - } - } - - private static string NormalizeOverlayService(string? service) - => string.Equals(service, "sigmoid", StringComparison.OrdinalIgnoreCase) ? "claude" : (service ?? "ollama").Trim().ToLowerInvariant(); - - private void CommitOverlayEndpointInput(bool normalizeOnInvalid) - { - var service = NormalizeOverlayService(_settings.Settings.Llm.Service); - var endpoint = TxtOverlayServiceEndpoint?.Text.Trim() ?? ""; - ClearOverlayValidation(TxtOverlayServiceEndpoint); - switch (service) - { - case "ollama": - _settings.Settings.Llm.OllamaEndpoint = endpoint; - _settings.Settings.Llm.Endpoint = endpoint; - break; - case "vllm": - _settings.Settings.Llm.VllmEndpoint = endpoint; - _settings.Settings.Llm.Endpoint = endpoint; - break; - default: - _settings.Settings.Llm.Endpoint = endpoint; - break; - } - - if (normalizeOnInvalid && TxtOverlayServiceEndpoint != null) - TxtOverlayServiceEndpoint.Text = endpoint; - } - - private void CommitOverlayApiKeyInput() - { - var service = NormalizeOverlayService(_settings.Settings.Llm.Service); - var apiKey = TxtOverlayServiceApiKey?.Text ?? ""; - switch (service) - { - case "ollama": - _settings.Settings.Llm.OllamaApiKey = apiKey; - _settings.Settings.Llm.ApiKey = apiKey; - break; - case "vllm": - _settings.Settings.Llm.VllmApiKey = apiKey; - _settings.Settings.Llm.ApiKey = apiKey; - break; - case "gemini": - _settings.Settings.Llm.GeminiApiKey = apiKey; - _settings.Settings.Llm.ApiKey = apiKey; - break; - default: - _settings.Settings.Llm.ClaudeApiKey = apiKey; - _settings.Settings.Llm.ApiKey = apiKey; - break; - } - } - - private void CommitOverlayModelInput(bool normalizeOnInvalid) - { - if (TxtOverlayModelInput == null || TxtOverlayModelInput.Visibility != Visibility.Visible) - return; - - var value = TxtOverlayModelInput?.Text.Trim() ?? ""; - if (string.IsNullOrWhiteSpace(value)) - { - MarkOverlayValidation(TxtOverlayModelInput, "모델명을 입력하세요."); - if (normalizeOnInvalid && TxtOverlayModelInput != null) - { - TxtOverlayModelInput.Text = _settings.Settings.Llm.Model ?? ""; - ClearOverlayValidation(TxtOverlayModelInput); - } - - return; - } - - ClearOverlayValidation(TxtOverlayModelInput); - _settings.Settings.Llm.Model = value; - var service = NormalizeOverlayService(_settings.Settings.Llm.Service); - switch (service) - { - case "ollama": - _settings.Settings.Llm.OllamaModel = value; - break; - case "vllm": - _settings.Settings.Llm.VllmModel = value; - break; - case "gemini": - _settings.Settings.Llm.GeminiModel = value; - break; - default: - _settings.Settings.Llm.ClaudeModel = value; - break; - } - } - - private bool CommitOverlayNumericInput(TextBox? textBox, int currentValue, int min, int max, Action applyValue, bool normalizeOnInvalid) - { - if (textBox == null) - return false; - - if (!int.TryParse(textBox.Text?.Trim(), out var parsed)) - { - MarkOverlayValidation(textBox, $"{min}~{max} 사이 숫자를 입력하세요."); - if (normalizeOnInvalid) - { - textBox.Text = Math.Clamp(currentValue, min, max).ToString(); - ClearOverlayValidation(textBox); - } - - return false; - } - - parsed = Math.Clamp(parsed, min, max); - applyValue(parsed); - textBox.Text = parsed.ToString(); - ClearOverlayValidation(textBox); - return true; - } - - private LlmSettings? TryGetOverlayLlmSettings() - => _settings?.Settings?.Llm; - - private void MarkOverlayValidation(Control? control, string message) - { - if (control == null) - return; - - control.BorderBrush = BrushFromHex("#DC2626"); - control.ToolTip = message; - } - - private void ClearOverlayValidation(Control? control) - { - if (control == null) - return; - - control.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - control.ClearValue(ToolTipProperty); - } - - private void CommitOverlayModelSelection(string modelId) - { - _settings.Settings.Llm.Model = modelId; - var service = NormalizeOverlayService(_settings.Settings.Llm.Service); - switch (service) - { - case "ollama": - _settings.Settings.Llm.OllamaModel = modelId; - break; - case "vllm": - _settings.Settings.Llm.VllmModel = modelId; - break; - case "gemini": - _settings.Settings.Llm.GeminiModel = modelId; - break; - default: - _settings.Settings.Llm.ClaudeModel = modelId; - break; - } - - if (TxtOverlayModelInput != null && TxtOverlayModelInput.Visibility == Visibility.Visible) - { - TxtOverlayModelInput.Text = modelId; - ClearOverlayValidation(TxtOverlayModelInput); - } - } - - private void ChkOverlayAiEnabled_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - _settings.Settings.AiEnabled = true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayVllmAllowInsecureTls_Changed(object sender, RoutedEventArgs e) - { - // vLLM SSL 우회는 모델 등록 단계에서만 관리합니다. - } - - private void TxtOverlayServiceEndpoint_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - CommitOverlayEndpointInput(normalizeOnInvalid: false); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayServiceApiKey_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - CommitOverlayApiKeyInput(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayModelInput_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - CommitOverlayModelInput(normalizeOnInvalid: false); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void RefreshOverlayAdvancedChoiceButtons() - { - // ToggleSwitch 기반으로 바뀌면서 별도 버튼 시각 동기화는 사용하지 않습니다. - } - - private void TxtOverlayContextCompactTriggerPercent_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, _settings.Settings.Llm.ContextCompactTriggerPercent, 10, 95, value => _settings.Settings.Llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxContextTokens_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxContextTokens, _settings.Settings.Llm.MaxContextTokens, 1024, 1_000_000, value => _settings.Settings.Llm.MaxContextTokens = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private bool CommitOverlayTemperatureInput(bool normalizeOnInvalid) - { - if (TxtOverlayTemperature == null) - return false; - - if (_settings.Settings.Llm.UseAutomaticProfileTemperature) - return false; - - var raw = TxtOverlayTemperature.Text.Trim(); - if (!double.TryParse(raw, out var parsed)) - { - ClearOverlayValidation(TxtOverlayTemperature); - if (normalizeOnInvalid) - TxtOverlayTemperature.Text = Math.Round(Math.Clamp(_settings.Settings.Llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); - return false; - } - - var normalized = Math.Round(Math.Clamp(parsed, 0.0, 2.0), 1); - var changed = Math.Abs(_settings.Settings.Llm.Temperature - normalized) > 0.0001; - _settings.Settings.Llm.Temperature = normalized; - ClearOverlayValidation(TxtOverlayTemperature); - if (normalizeOnInvalid || changed) - TxtOverlayTemperature.Text = normalized.ToString("0.0"); - return changed; - } - - private void TxtOverlayTemperature_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayTemperatureInput(normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayTemperature_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - if (llm.UseAutomaticProfileTemperature) - return; - - var value = Math.Round(Math.Clamp(e.NewValue, 0.0, 2.0), 1); - llm.Temperature = value; - if (TxtOverlayTemperature != null) - TxtOverlayTemperature.Text = value.ToString("0.0"); - if (TxtOverlayTemperatureValue != null) - TxtOverlayTemperatureValue.Text = value.ToString("0.0"); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayTemperatureAutoCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.UseAutomaticProfileTemperature = true; - RefreshOverlayTemperatureModeButtons(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayTemperatureCustomCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.UseAutomaticProfileTemperature = false; - RefreshOverlayTemperatureModeButtons(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayMaxRetryOnError_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 10)); - llm.MaxRetryOnError = value; - if (TxtOverlayMaxRetryOnError != null) - TxtOverlayMaxRetryOnError.Text = value.ToString(); - if (TxtOverlayMaxRetryOnErrorValue != null) - TxtOverlayMaxRetryOnErrorValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayMaxAgentIterations_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 100)); - llm.MaxAgentIterations = value; - if (TxtOverlayMaxAgentIterations != null) - TxtOverlayMaxAgentIterations.Text = value.ToString(); - if (TxtOverlayMaxAgentIterationsValue != null) - TxtOverlayMaxAgentIterationsValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayFreeTierDelaySeconds_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 60)); - llm.FreeTierDelaySeconds = value; - if (TxtOverlayFreeTierDelaySeconds != null) - TxtOverlayFreeTierDelaySeconds.Text = value.ToString(); - if (TxtOverlayFreeTierDelaySecondsValue != null) - TxtOverlayFreeTierDelaySecondsValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayMaxSubAgents_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 10)); - llm.MaxSubAgents = value; - if (TxtOverlayMaxSubAgents != null) - TxtOverlayMaxSubAgents.Text = value.ToString(); - if (TxtOverlayMaxSubAgentsValue != null) - TxtOverlayMaxSubAgentsValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayToolHookTimeoutMs_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 3000, 30000)); - llm.ToolHookTimeoutMs = value; - if (TxtOverlayToolHookTimeoutMs != null) - TxtOverlayToolHookTimeoutMs.Text = value.ToString(); - if (TxtOverlayToolHookTimeoutMsValue != null) - TxtOverlayToolHookTimeoutMsValue.Text = $"{value / 1000}s"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlaySlashPopupPageSize_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 3, 20)); - llm.SlashPopupPageSize = value; - if (TxtOverlaySlashPopupPageSize != null) - TxtOverlaySlashPopupPageSize.Text = value.ToString(); - if (TxtOverlaySlashPopupPageSizeValue != null) - TxtOverlaySlashPopupPageSizeValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayMaxFavoriteSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 30)); - llm.MaxFavoriteSlashCommands = value; - if (TxtOverlayMaxFavoriteSlashCommands != null) - TxtOverlayMaxFavoriteSlashCommands.Text = value.ToString(); - if (TxtOverlayMaxFavoriteSlashCommandsValue != null) - TxtOverlayMaxFavoriteSlashCommandsValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void SldOverlayMaxRecentSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) - { - var llm = TryGetOverlayLlmSettings(); - if (_isOverlaySettingsSyncing || llm == null) - return; - - var value = (int)Math.Round(Math.Clamp(e.NewValue, 5, 50)); - llm.MaxRecentSlashCommands = value; - if (TxtOverlayMaxRecentSlashCommands != null) - TxtOverlayMaxRecentSlashCommands.Text = value.ToString(); - if (TxtOverlayMaxRecentSlashCommandsValue != null) - TxtOverlayMaxRecentSlashCommandsValue.Text = value.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayCompactPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value)) - return; - - _settings.Settings.Llm.ContextCompactTriggerPercent = Math.Clamp(value, 10, 95); - if (TxtOverlayContextCompactTriggerPercent != null) - TxtOverlayContextCompactTriggerPercent.Text = _settings.Settings.Llm.ContextCompactTriggerPercent.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayContextPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value)) - return; - - _settings.Settings.Llm.MaxContextTokens = Math.Clamp(value, 1024, 1_000_000); - if (TxtOverlayMaxContextTokens != null) - TxtOverlayMaxContextTokens.Text = _settings.Settings.Llm.MaxContextTokens.ToString(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxRetryOnError_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, _settings.Settings.Llm.MaxRetryOnError, 0, 10, value => _settings.Settings.Llm.MaxRetryOnError = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxAgentIterations_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, _settings.Settings.Llm.MaxAgentIterations, 1, 200, value => _settings.Settings.Llm.MaxAgentIterations = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayFreeTierDelaySeconds_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, _settings.Settings.Llm.FreeTierDelaySeconds, 0, 60, value => _settings.Settings.Llm.FreeTierDelaySeconds = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxSubAgents_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxSubAgents, _settings.Settings.Llm.MaxSubAgents, 1, 10, value => _settings.Settings.Llm.MaxSubAgents = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlaySlashPopupPageSize_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlaySlashPopupPageSize, _settings.Settings.Llm.SlashPopupPageSize, 3, 20, value => _settings.Settings.Llm.SlashPopupPageSize = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayToolHookTimeoutMs_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, _settings.Settings.Llm.ToolHookTimeoutMs, 3000, 30000, value => _settings.Settings.Llm.ToolHookTimeoutMs = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxFavoriteSlashCommands_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, _settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30, value => _settings.Settings.Llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayMaxRecentSlashCommands_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, _settings.Settings.Llm.MaxRecentSlashCommands, 5, 50, value => _settings.Settings.Llm.MaxRecentSlashCommands = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayPdfExportPath_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || TxtOverlayPdfExportPath == null) - return; - - _settings.Settings.Llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayPlanDiffMediumCount_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, _settings.Settings.Llm.PlanDiffSeverityMediumCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayPlanDiffHighCount_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, _settings.Settings.Llm.PlanDiffSeverityHighCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayPlanDiffMediumRatio_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void TxtOverlayPlanDiffHighRatio_LostFocus(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: false)) - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayBrowseSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - var dlg = new System.Windows.Forms.FolderBrowserDialog - { - Description = "스킬 파일이 있는 폴더를 선택하세요", - ShowNewFolderButton = true, - }; - - var current = _settings.Settings.Llm.SkillsFolderPath; - if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current)) - dlg.SelectedPath = current; - - if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) - return; - - _settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath; - SkillService.LoadSkills(dlg.SelectedPath); - RefreshOverlayEtcPanels(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayOpenSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - var folder = !string.IsNullOrWhiteSpace(_settings.Settings.Llm.SkillsFolderPath) && Directory.Exists(_settings.Settings.Llm.SkillsFolderPath) - ? _settings.Settings.Llm.SkillsFolderPath - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); - - if (!Directory.Exists(folder)) - Directory.CreateDirectory(folder); - - try { System.Diagnostics.Process.Start("explorer.exe", folder); } catch { } - } - - private void OverlayAddHookBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - => ShowOverlayHookEditDialog(null, -1); - - private void OverlayOpenAuditLogBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - // 클릭 효과 리셋 - if (sender is Border border) - { - border.Opacity = 1.0; - border.RenderTransform = null; - } - try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { } - } - - private void BtnBrowsePdfExportPath_Click(object sender, MouseButtonEventArgs e) - { - // 클릭 효과 리셋 - if (sender is Border border) - { - border.Opacity = 1.0; - border.RenderTransform = null; - } - - var dlg = new System.Windows.Forms.FolderBrowserDialog - { - Description = "PDF 내보내기 기본 폴더를 선택하세요", - ShowNewFolderButton = true, - UseDescriptionForTitle = true, - }; - - var current = _settings.Settings.Llm.PdfExportPath; - if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current)) - dlg.SelectedPath = current; - - if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) - return; - - _settings.Settings.Llm.PdfExportPath = dlg.SelectedPath; - if (TxtOverlayPdfExportPath != null) - TxtOverlayPdfExportPath.Text = dlg.SelectedPath; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - /// 설정 오버레이 액션 버튼 공통 호버/클릭 효과. - private void OverlayActionBtn_MouseEnter(object sender, MouseEventArgs e) - { - if (sender is Border border) - { - border.Opacity = 0.85; - border.Background = TryFindResource("ItemActiveBackground") as Brush - ?? TryFindResource("ItemHoverBackground") as Brush - ?? border.Background; - } - } - - private void OverlayActionBtn_MouseLeave(object sender, MouseEventArgs e) - { - if (sender is Border border) - { - border.Opacity = 1.0; - border.Background = TryFindResource("ItemHoverBackground") as Brush ?? border.Background; - } - } - - private void OverlayActionBtn_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - if (sender is Border border) - { - border.Opacity = 0.65; - border.RenderTransform = new ScaleTransform(0.96, 0.96); - border.RenderTransformOrigin = new Point(0.5, 0.5); - } - } - - private void ChkOverlayEnableDragDropAiActions_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayEnableDragDropAiActions == null) - return; - - _settings.Settings.Llm.EnableDragDropAiActions = ChkOverlayEnableDragDropAiActions.IsChecked == true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayDragDropAutoSend_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayDragDropAutoSend == null) - return; - - _settings.Settings.Llm.DragDropAutoSend = ChkOverlayDragDropAutoSend.IsChecked == true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayWorkflowVisualizer_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayWorkflowVisualizer == null) - return; - - _settings.Settings.Llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer.IsChecked == true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayShowTotalCallStats_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayShowTotalCallStats == null) - return; - - _settings.Settings.Llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats.IsChecked == true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayEnableAuditLog_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayEnableAuditLog == null) - return; - - _settings.Settings.Llm.EnableAuditLog = ChkOverlayEnableAuditLog.IsChecked == true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayEnableDetailedLog_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayEnableDetailedLog == null) - return; - - var enabled = ChkOverlayEnableDetailedLog.IsChecked == true; - _settings.Settings.Llm.EnableDetailedLog = enabled; - WorkflowLogService.IsEnabled = enabled; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayEnableRawLlmLog_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing || ChkOverlayEnableRawLlmLog == null) - return; - - var enabled = ChkOverlayEnableRawLlmLog.IsChecked == true; - _settings.Settings.Llm.EnableRawLlmLog = enabled; - WorkflowLogService.IsRawLogEnabled = enabled; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void ChkOverlayFeatureToggle_Changed(object sender, RoutedEventArgs e) - { - if (_isOverlaySettingsSyncing) - return; - - ApplyOverlaySettingsChanges(showToast: false, closeOverlay: false); - } - - private void OverlayNav_Checked(object sender, RoutedEventArgs e) - { - if (sender is not RadioButton rb || rb.Tag is not string tag) - return; - - SetOverlaySection(tag); - } - - private void SetOverlaySection(string tag) - { - if (OverlaySectionService == null || OverlaySectionQuick == null || OverlaySectionDetail == null) - return; - - var section = string.IsNullOrWhiteSpace(tag) ? "basic" : tag.Trim().ToLowerInvariant(); - var showBasic = section == "basic"; - var showChat = section == "chat"; - var showShared = section == "shared"; - var showCowork = section == "cowork"; - var showCode = section == "code"; - var showDev = section == "dev"; - var showTools = section == "tools"; - var showSkill = section == "skill"; - var showBlock = section == "block"; - - OverlaySectionService.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - OverlaySectionQuick.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; - OverlaySectionDetail.Visibility = Visibility.Visible; - - var headingTitle = section switch - { - "chat" => "채팅 설정", - "shared" => "코워크/코드 공통 설정", - "cowork" => "코워크 설정", - "code" => "코드 설정", - "dev" => "개발자 설정", - "tools" => "도구 설정", - "skill" => "스킬 설정", - "block" => "차단 설정", - _ => "공통 설정" - }; - var headingDescription = section switch - { - "chat" => "Chat 탭에서 쓰는 입력/내보내기 같은 채팅 전용 설정입니다.", - "shared" => "Cowork와 Code에서 함께 쓰는 문맥/압축 관련 기본 설정입니다.", - "cowork" => "문서/업무 협업 흐름에 맞춘 코워크 전용 설정입니다.", - "code" => "코드 작업, 검증, 개발 도구 사용에 맞춘 설정입니다.", - "dev" => "실행 이력, 감사, 시각화 같은 개발자용 설정입니다.", - "tools" => "AX Agent가 사용할 도구와 훅 동작을 관리합니다.", - "skill" => "슬래시 스킬, 스킬 폴더, 폴백 모델, MCP 연결을 관리합니다.", - "block" => "에이전트가 접근하거나 수정하면 안 되는 경로와 형식을 관리합니다.", - _ => "Chat, Cowork, Code에서 공통으로 쓰는 기본 설정입니다." - }; - - if (OverlayTopHeadingTitle != null) - OverlayTopHeadingTitle.Text = headingTitle; - if (OverlayTopHeadingDescription != null) - OverlayTopHeadingDescription.Text = headingDescription; - if (OverlayAnchorCommon != null) - OverlayAnchorCommon.Text = headingTitle; - - if (OverlayAiEnabledRow != null) - OverlayAiEnabledRow.Visibility = Visibility.Collapsed; - if (OverlayThemePanel != null) - OverlayThemePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - if (OverlayThemeStylePanel != null) - OverlayThemeStylePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - if (OverlayPdfExportPathRow != null) - OverlayPdfExportPathRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleImageInput != null) - OverlayToggleImageInput.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; - if (OverlayModelEditorPanel != null) - OverlayModelEditorPanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - if (OverlayAnchorPermission != null) - OverlayAnchorPermission.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - if (OverlayTlsRow != null) - OverlayTlsRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; - if (OverlayAnchorAdvanced != null) - OverlayAnchorAdvanced.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; - if (TxtOverlayContextCompactTriggerPercent != null) - TxtOverlayContextCompactTriggerPercent.Visibility = Visibility.Collapsed; - if (OverlayMaxContextTokensRow != null) - OverlayMaxContextTokensRow.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; - if (OverlayTemperatureRow != null) - OverlayTemperatureRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayMaxRetryRow != null) - OverlayMaxRetryRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayMaxAgentIterationsRow != null) - OverlayMaxAgentIterationsRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayDeveloperRuntimePanel != null) - OverlayDeveloperRuntimePanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayDeveloperExtraPanel != null) - OverlayDeveloperExtraPanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayAdvancedTogglePanel != null) - OverlayAdvancedTogglePanel.Visibility = showDev || showCowork || showCode || showTools || showSkill ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToolsInfoPanel != null) - OverlayToolsInfoPanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToolsRuntimePanel != null) - OverlayToolsRuntimePanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToolRegistrySection != null) - OverlayToolRegistrySection.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlaySkillInfoPanel != null) - OverlaySkillInfoPanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed; - if (OverlaySkillRuntimePanel != null) - OverlaySkillRuntimePanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed; - if (OverlayBlockInfoPanel != null) - OverlayBlockInfoPanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed; - if (OverlayBlockRuntimePanel != null) - OverlayBlockRuntimePanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleProactiveCompact != null) - OverlayToggleProactiveCompact.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleSkillSystem != null) - OverlayToggleSkillSystem.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleToolHooks != null) - OverlayToggleToolHooks.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleHookInputMutation != null) - OverlayToggleHookInputMutation.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleHookPermissionUpdate != null) - OverlayToggleHookPermissionUpdate.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleCoworkVerification != null) - OverlayToggleCoworkVerification.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleCodeVerification != null) - OverlayToggleCodeVerification.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleCodeReview != null) - OverlayToggleCodeReview.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleParallelTools != null) - OverlayToggleParallelTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleProjectRules != null) - OverlayToggleProjectRules.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleAgentMemory != null) - OverlayToggleAgentMemory.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleWorktreeTools != null) - OverlayToggleWorktreeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleTeamTools != null) - OverlayToggleTeamTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlayToggleCronTools != null) - OverlayToggleCronTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; - if (OverlaySectionGlowEffects != null) - OverlaySectionGlowEffects.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; - - if (showTools || showSkill || showBlock) - RefreshOverlayEtcPanels(); - } - - private void RefreshOverlaySettingsPanel() - { - // 기본 컨트롤 상태만 동기적으로 설정 (빠름) - RefreshOverlayVisualState(loadDeferredInputs: true); - - // 무거운 패널 빌드(스킬/MCP/도구 목록 등)는 UI 프레임 렌더링 후 비동기 지연 실행 - // → 스트리밍 중 설정 열기 시 UI 프리즈 방지 - Dispatcher.BeginInvoke(RefreshOverlayEtcPanels, System.Windows.Threading.DispatcherPriority.Background); - } - - private void RefreshOverlayRetentionButtons() - { - // 대화 관리 섹션이 오버레이에서 제거됨 (설정 탭에서 관리) - } - - private void ApplyOverlayRetentionButtonState(Button? button, bool selected) - { - if (button == null) - return; - - var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - var border = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; - var hint = TryFindResource("HintBackground") as Brush ?? Brushes.Transparent; - var primary = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - - button.Background = selected ? hint : Brushes.Transparent; - button.BorderBrush = selected ? accent : border; - button.BorderThickness = new Thickness(1); - button.Foreground = selected ? accent : primary; - button.FontWeight = selected ? FontWeights.SemiBold : FontWeights.Normal; - button.Cursor = Cursors.Hand; - } - - private void RefreshOverlayStorageSummary() - { - if (OverlayStorageSummaryText == null || OverlayStorageDriveText == null) - return; - - var report = StorageAnalyzer.Analyze(); - var appTotal = report.Conversations + report.AuditLogs + report.Logs + report.CodeIndex + report.EmbeddingDb + report.ClipboardHistory + report.Plugins + report.Skills + report.Settings; - OverlayStorageSummaryText.Text = $"앱 전체 사용량: {FormatStorageBytes(appTotal)}"; - - if (!string.IsNullOrWhiteSpace(report.DriveLabel) && report.DriveTotalSpace > 0) - { - var used = report.DriveTotalSpace - report.DriveFreeSpace; - var percent = report.DriveTotalSpace == 0 ? 0 : (int)Math.Round((double)used / report.DriveTotalSpace * 100); - OverlayStorageDriveText.Text = $"{report.DriveLabel} · 사용 {percent}% · 여유 {FormatStorageBytes(report.DriveFreeSpace)}"; - } - else - { - OverlayStorageDriveText.Text = "로컬 앱 데이터 폴더 기준 사용량입니다."; - } - } - - private void BtnOverlayRetention_Click(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement element) - return; - - var retainDays = element.Name switch - { - "BtnOverlayRetention7" => 7, - "BtnOverlayRetention30" => 30, - "BtnOverlayRetention90" => 90, - "BtnOverlayRetentionUnlimited" => 0, - _ => _settings.Settings.Llm.RetentionDays - }; - - _settings.Settings.Llm.RetentionDays = retainDays; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - RefreshOverlayRetentionButtons(); - } - - private void BtnOverlayStorageRefresh_Click(object sender, RoutedEventArgs e) - { - RefreshOverlayStorageSummary(); - } - - private void BtnOverlayDeleteAllConversations_Click(object sender, RoutedEventArgs e) - { - BtnDeleteAll_Click(sender, e); - RefreshOverlayStorageSummary(); - } - - private void BtnOverlayStorageCleanup_Click(object sender, RoutedEventArgs e) - { - var retainDays = Math.Max(0, _settings.Settings.Llm.RetentionDays); - var cleanedBytes = StorageAnalyzer.Cleanup( - retainDays, - cleanConversations: false, - cleanAuditLogs: true, - cleanLogs: true, - cleanCodeIndex: true, - cleanClipboard: true); - - RefreshOverlayStorageSummary(); - - CustomMessageBox.Show( - cleanedBytes > 0 - ? $"저장 공간을 정리했습니다.\n확보된 공간: {StorageAnalyzer.FormatSize(cleanedBytes)}" - : "정리할 항목이 없었습니다.", - "저장 공간 정리", - MessageBoxButton.OK, - MessageBoxImage.Information); - } - - private static string FormatStorageBytes(long bytes) - { - if (bytes >= 1024L * 1024 * 1024) - return $"{bytes / 1024.0 / 1024 / 1024:F1} GB"; - if (bytes >= 1024L * 1024) - return $"{bytes / 1024.0 / 1024:F1} MB"; - if (bytes >= 1024L) - return $"{bytes / 1024.0:F0} KB"; - return $"{bytes} B"; - } - - private void RefreshOverlayEtcPanels() - { - var llm = _settings.Settings.Llm; - - if (OverlaySkillsFolderPathText != null) - { - var defaultFolder = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AxCopilot", - "skills"); - OverlaySkillsFolderPathText.Text = string.IsNullOrWhiteSpace(llm.SkillsFolderPath) - ? defaultFolder - : llm.SkillsFolderPath.Trim(); - } - - if (TxtOverlaySlashPopupPageSize != null) - TxtOverlaySlashPopupPageSize.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString(); - if (SldOverlaySlashPopupPageSize != null) - SldOverlaySlashPopupPageSize.Value = Math.Clamp(llm.SlashPopupPageSize, 3, 20); - if (TxtOverlaySlashPopupPageSizeValue != null) - TxtOverlaySlashPopupPageSizeValue.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString(); - if (TxtOverlayToolHookTimeoutMs != null) - TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString(); - if (SldOverlayToolHookTimeoutMs != null) - SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000); - if (TxtOverlayToolHookTimeoutMsValue != null) - TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s"; - if (TxtOverlayMaxFavoriteSlashCommands != null) - TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); - if (SldOverlayMaxFavoriteSlashCommands != null) - SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30); - if (TxtOverlayMaxFavoriteSlashCommandsValue != null) - TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); - if (TxtOverlayMaxRecentSlashCommands != null) - TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); - if (SldOverlayMaxRecentSlashCommands != null) - SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50); - if (TxtOverlayMaxRecentSlashCommandsValue != null) - TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); - if (ChkOverlayEnableDragDropAiActions != null) - ChkOverlayEnableDragDropAiActions.IsChecked = llm.EnableDragDropAiActions; - if (ChkOverlayDragDropAutoSend != null) - ChkOverlayDragDropAutoSend.IsChecked = llm.DragDropAutoSend; - - BuildOverlayBlockedItems(); - BuildOverlayHookCards(); - BuildOverlaySkillListPanel(); - BuildOverlayFallbackModelsPanel(); - BuildOverlayMcpServerCards(); - BuildOverlayToolRegistryPanel(); - } - - private void BuildOverlayBlockedItems() - { - if (OverlayBlockedPathsPanel != null) - { - OverlayBlockedPathsPanel.Children.Clear(); - foreach (var path in _settings.Settings.Llm.BlockedPaths.Where(x => !string.IsNullOrWhiteSpace(x))) - { - OverlayBlockedPathsPanel.Children.Add(new Border - { - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 0, 0, 4), - Child = new TextBlock - { - Text = path, - FontSize = 11.5, - FontFamily = new FontFamily("Consolas, Malgun Gothic"), - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray - } - }); - } - } - - if (OverlayBlockedExtensionsPanel != null) - { - OverlayBlockedExtensionsPanel.Children.Clear(); - foreach (var ext in _settings.Settings.Llm.BlockedExtensions.Where(x => !string.IsNullOrWhiteSpace(x))) - { - OverlayBlockedExtensionsPanel.Children.Add(new Border - { - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 0, 6, 6), - Child = new TextBlock - { - Text = ext, - FontSize = 11.5, - FontFamily = new FontFamily("Consolas, Malgun Gothic"), - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray - } - }); - } - } - } - - private void BuildOverlaySkillListPanel() - { - if (OverlaySkillListPanel == null) - return; - - OverlaySkillListPanel.Children.Clear(); - var skills = SkillService.Skills.ToList(); - if (skills.Count == 0) - { - OverlaySkillListPanel.Children.Add(new TextBlock - { - Text = "로드된 스킬이 없습니다.", - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - return; - } - - var unavailable = skills - .Where(skill => !skill.IsAvailable) - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - var autoSkills = skills - .Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths))) - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - var directSkills = skills - .Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths)) - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - AddOverlaySkillSection("overlay-skill-direct", "직접 호출 스킬", "슬래시(/)로 직접 실행하는 스킬입니다.", directSkills, "#2563EB"); - AddOverlaySkillSection("overlay-skill-auto", "자동/조건부 스킬", "조건에 따라 자동으로 붙거나 보조적으로 동작하는 스킬입니다.", autoSkills, "#0F766E"); - AddOverlaySkillSection("overlay-skill-unavailable", "현재 사용 불가", "필요한 런타임이 없어 지금은 호출되지 않는 스킬입니다.", unavailable, "#9A3412"); - } - - private void AddOverlaySkillSection(string key, string title, string subtitle, List skills, string accentHex) - { - if (OverlaySkillListPanel == null || skills.Count == 0) - return; - - var body = new StackPanel(); - foreach (var skill in skills) - { - var card = new Border - { - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 6), - }; - - var stack = new StackPanel(); - stack.Children.Add(new TextBlock - { - Text = "/" + skill.Name, - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black - }); - - if (!string.IsNullOrWhiteSpace(skill.Label) && - !string.Equals(skill.Label.Trim(), skill.Name, StringComparison.OrdinalIgnoreCase)) - { - stack.Children.Add(new TextBlock - { - Text = skill.Label.Trim(), - Margin = new Thickness(0, 3, 0, 0), - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - } - - stack.Children.Add(new TextBlock - { - Text = string.IsNullOrWhiteSpace(skill.Description) - ? (string.IsNullOrWhiteSpace(skill.Label) ? skill.Name : skill.Label) - : skill.Description, - Margin = new Thickness(0, 4, 0, 0), - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - - card.Child = stack; - body.Children.Add(card); - } - - OverlaySkillListPanel.Children.Add(CreateOverlayCollapsibleSection( - key, - $"{title} ({skills.Count})", - subtitle, - body, - defaultExpanded: false, - accentHex: accentHex)); - } - - private void BuildOverlayFallbackModelsPanel() - { - if (OverlayFallbackModelsPanel == null) - return; - - OverlayFallbackModelsPanel.Children.Clear(); - var llm = _settings.Settings.Llm; - var sections = new[] - { - ("ollama", "Ollama"), - ("vllm", "vLLM"), - ("gemini", "Gemini"), - ("claude", "Claude") - }; - - foreach (var (service, label) in sections) - { - var candidates = GetModelCandidates(service); - OverlayFallbackModelsPanel.Children.Add(new TextBlock - { - Text = label, - FontSize = 11.5, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, - Margin = new Thickness(0, 0, 0, 6) - }); - - if (candidates.Count == 0) - { - OverlayFallbackModelsPanel.Children.Add(new TextBlock - { - Text = "등록된 모델 없음", - FontSize = 10.5, - Margin = new Thickness(8, 0, 0, 8), - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - continue; - } - - foreach (var candidate in candidates) - { - var enabled = llm.FallbackModels.Any(x => x.Equals(candidate.Id, StringComparison.OrdinalIgnoreCase)); - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition()); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var nameText = new TextBlock - { - Text = candidate.Label, - FontSize = 11.5, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black - }; - var stateText = new TextBlock - { - Text = enabled ? "사용" : "미사용", - FontSize = 10.5, - Foreground = enabled - ? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue) - : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), - Margin = new Thickness(12, 0, 0, 0) - }; - Grid.SetColumn(stateText, 1); - grid.Children.Add(nameText); - grid.Children.Add(stateText); - - OverlayFallbackModelsPanel.Children.Add(new Border - { - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 7, 10, 7), - Margin = new Thickness(0, 0, 0, 6), - Child = grid - }); - } - } - } - - private void BuildOverlayMcpServerCards() - { - if (OverlayMcpServerListPanel == null) - return; - - OverlayMcpServerListPanel.Children.Clear(); - var servers = _settings.Settings.Llm.McpServers; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; - var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.White; - var cardBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - - if (servers == null || servers.Count == 0) - { - OverlayMcpServerListPanel.Children.Add(new TextBlock - { - Text = "등록된 MCP 서버가 없습니다.", - FontSize = 11, - Foreground = secondaryText - }); - return; - } - - Border CreateActionChip(string text, Brush foreground, Action onClick) - { - var border = new Border - { - Background = Brushes.Transparent, - BorderBrush = Brushes.Transparent, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(6, 0, 0, 0), - Cursor = Cursors.Hand, - }; - - var label = new TextBlock - { - Text = text, - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = foreground, - VerticalAlignment = VerticalAlignment.Center, - }; - - border.Child = label; - border.MouseEnter += (_, _) => - { - border.Background = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)); - border.BorderBrush = borderBrush; - }; - border.MouseLeave += (_, _) => - { - border.Background = Brushes.Transparent; - border.BorderBrush = Brushes.Transparent; - }; - border.MouseLeftButtonUp += (_, _) => onClick(); - return border; - } - - for (int index = 0; index < servers.Count; index++) - { - var server = servers[index]; - var card = new Border - { - Background = cardBackground, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 10, 12, 10), - Margin = new Thickness(0, 0, 0, 6) - }; - - var root = new StackPanel(); - var header = new Grid(); - header.ColumnDefinitions.Add(new ColumnDefinition()); - header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var title = new TextBlock - { - Text = server.Name, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center - }; - header.Children.Add(title); - - var actions = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - }; - actions.Children.Add(CreateActionChip(server.Enabled ? "비활성화" : "활성화", accentBrush, () => - { - _settings.Settings.Llm.McpServers[index].Enabled = !_settings.Settings.Llm.McpServers[index].Enabled; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - BuildOverlayMcpServerCards(); - })); - actions.Children.Add(CreateActionChip("삭제", BrushFromHex("#DC2626"), () => - { - var result = CustomMessageBox.Show($"'{server.Name}' 서버를 삭제하시겠습니까?", "MCP 서버 삭제", - MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result != MessageBoxResult.Yes) - return; - - _settings.Settings.Llm.McpServers.RemoveAt(index); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - BuildOverlayMcpServerCards(); - })); - Grid.SetColumn(actions, 1); - header.Children.Add(actions); - root.Children.Add(header); - - var commandCard = new Border - { - Background = itemBackground, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 8, 0, 0), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = server.Command, - FontSize = 10.8, - TextWrapping = TextWrapping.Wrap, - Foreground = primaryText, - }, - new TextBlock - { - Text = server.Enabled ? "활성 상태" : "비활성 상태", - Margin = new Thickness(0, 4, 0, 0), - FontSize = 10.5, - Foreground = secondaryText, - } - } - } - }; - root.Children.Add(commandCard); - card.Child = root; - OverlayMcpServerListPanel.Children.Add(card); - } - } - - private void BtnOverlayAddMcpServer_Click(object sender, RoutedEventArgs e) - { - var nameDialog = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server") - { - Owner = this - }; - if (nameDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(nameDialog.ResponseText)) - return; - - var commandDialog = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @modelcontextprotocol/server-filesystem") - { - Owner = this - }; - if (commandDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(commandDialog.ResponseText)) - return; - - _settings.Settings.Llm.McpServers.Add(new Models.McpServerEntry - { - Name = nameDialog.ResponseText.Trim(), - Command = commandDialog.ResponseText.Trim(), - Enabled = true, - }); - - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - BuildOverlayMcpServerCards(); - } - - private void BuildOverlayToolRegistryPanel() - { - if (OverlayToolRegistryPanel == null) - return; - - OverlayToolRegistryPanel.Children.Clear(); - var grouped = _toolRegistry.All - .GroupBy(tool => GetOverlayToolCategory(tool.Name)) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var group in grouped) - { - var body = new StackPanel(); - foreach (var tool in group.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) - { - var isDisabled = _settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase); - var row = new Border - { - Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 6), - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition()); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var rowStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) }; - rowStack.Children.Add(new TextBlock - { - Text = tool.Name, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = isDisabled - ? (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray) - : (TryFindResource("PrimaryText") as Brush ?? Brushes.Black) - }); - rowStack.Children.Add(new TextBlock - { - Text = (isDisabled ? "비활성" : "활성") + " · " + tool.Description, - Margin = new Thickness(0, 4, 0, 0), - FontSize = 10.8, - TextWrapping = TextWrapping.Wrap, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - grid.Children.Add(rowStack); - - var toggle = new CheckBox - { - IsChecked = !isDisabled, - VerticalAlignment = VerticalAlignment.Center, - Style = TryFindResource("ToggleSwitch") as Style, - }; - toggle.Checked += (_, _) => - { - _settings.Settings.Llm.DisabledTools.RemoveAll(name => string.Equals(name, tool.Name, StringComparison.OrdinalIgnoreCase)); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - RefreshOverlayEtcPanels(); - }; - toggle.Unchecked += (_, _) => - { - if (!_settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase)) - _settings.Settings.Llm.DisabledTools.Add(tool.Name); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - RefreshOverlayEtcPanels(); - }; - Grid.SetColumn(toggle, 1); - grid.Children.Add(toggle); - - row.Child = grid; - body.Children.Add(row); - } - - OverlayToolRegistryPanel.Children.Add(CreateOverlayCollapsibleSection( - "overlay-tool-" + group.Key, - $"{group.Key} ({group.Count()})", - "카테고리별 도구 목록입니다. 펼치면 상세 이름과 사용 여부를 바로 바꿀 수 있습니다.", - body, - defaultExpanded: false, - accentHex: "#4F46E5")); - } - } - - private void BuildOverlayHookCards() - { - if (OverlayHookListPanel == null) - return; - - OverlayHookListPanel.Children.Clear(); - - var hooks = _settings.Settings.Llm.AgentHooks; - if (hooks.Count == 0) - { - OverlayHookListPanel.Children.Add(new TextBlock - { - Text = "등록된 훅이 없습니다.", - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray - }); - return; - } - - var body = new StackPanel(); - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - - for (int i = 0; i < hooks.Count; i++) - { - var hook = hooks[i]; - var idx = i; - var card = new Border - { - Background = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke, - BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 6), - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition()); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var toggle = new CheckBox - { - IsChecked = hook.Enabled, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - Style = TryFindResource("ToggleSwitch") as Style, - }; - toggle.Checked += (_, _) => - { - hook.Enabled = true; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - }; - toggle.Unchecked += (_, _) => - { - hook.Enabled = false; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - }; - Grid.SetColumn(toggle, 0); - grid.Children.Add(toggle); - - var info = new StackPanel(); - var header = new StackPanel { Orientation = Orientation.Horizontal }; - header.Children.Add(new TextBlock - { - Text = hook.Name, - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - }); - header.Children.Add(new Border - { - Background = BrushFromHex(hook.Timing == "pre" ? "#FFEDD5" : "#DCFCE7"), - BorderBrush = BrushFromHex(hook.Timing == "pre" ? "#FDBA74" : "#86EFAC"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(5), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(6, 0, 0, 0), - Child = new TextBlock - { - Text = hook.Timing == "pre" ? "PRE" : "POST", - FontSize = 9.5, - FontWeight = FontWeights.Bold, - Foreground = BrushFromHex(hook.Timing == "pre" ? "#9A3412" : "#166534"), - } - }); - if (!string.IsNullOrWhiteSpace(hook.ToolName) && hook.ToolName != "*") - { - header.Children.Add(new Border - { - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(5), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(6, 0, 0, 0), - Child = new TextBlock - { - Text = hook.ToolName, - FontSize = 9.5, - Foreground = BrushFromHex("#3730A3"), - } - }); - } - info.Children.Add(header); - info.Children.Add(new TextBlock - { - Text = Path.GetFileName(hook.ScriptPath), - Margin = new Thickness(0, 4, 0, 0), - FontSize = 11, - Foreground = secondaryText - }); - if (!string.IsNullOrWhiteSpace(hook.Arguments)) - { - info.Children.Add(new TextBlock - { - Text = hook.Arguments, - Margin = new Thickness(0, 3, 0, 0), - FontSize = 10.5, - TextWrapping = TextWrapping.Wrap, - Foreground = secondaryText - }); - } - Grid.SetColumn(info, 1); - grid.Children.Add(info); - - var editBtn = new Border - { - Cursor = Cursors.Hand, - Padding = new Thickness(6), - Margin = new Thickness(6, 0, 2, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = "\uE70F", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12, - Foreground = accentBrush, - } - }; - editBtn.MouseLeftButtonUp += (_, _) => ShowOverlayHookEditDialog(hooks[idx], idx); - Grid.SetColumn(editBtn, 2); - grid.Children.Add(editBtn); - - var deleteBtn = new Border - { - Cursor = Cursors.Hand, - Padding = new Thickness(6), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = "\uE74D", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12, - Foreground = BrushFromHex("#DC2626"), - } - }; - deleteBtn.MouseLeftButtonUp += (_, _) => - { - hooks.RemoveAt(idx); - BuildOverlayHookCards(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - }; - Grid.SetColumn(deleteBtn, 3); - grid.Children.Add(deleteBtn); - - card.Child = grid; - body.Children.Add(card); - } - - OverlayHookListPanel.Children.Add(CreateOverlayCollapsibleSection( - "overlay-hooks", - $"등록된 훅 ({hooks.Count})", - "도구 실행 전후에 연결되는 스크립트입니다.", - body, - defaultExpanded: false, - accentHex: "#0F766E")); - } - - private Border CreateOverlayCollapsibleSection(string key, string title, string subtitle, UIElement content, bool defaultExpanded, string accentHex) - { - var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; - var launcherBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var expanded = _overlaySectionExpandedStates.TryGetValue(key, out var stored) ? stored : defaultExpanded; - - var bodyBorder = new Border - { - Margin = new Thickness(0, 10, 0, 0), - Child = content, - Visibility = expanded ? Visibility.Visible : Visibility.Collapsed - }; - - var caret = new TextBlock - { - Text = expanded ? "\uE70D" : "\uE76C", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center - }; - - var headerGrid = new Grid(); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var headerText = new StackPanel(); - headerText.Children.Add(new TextBlock - { - Text = title, - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText - }); - if (!string.IsNullOrWhiteSpace(subtitle)) - { - headerText.Children.Add(new TextBlock - { - Text = subtitle, - Margin = new Thickness(0, 4, 0, 0), - FontSize = 10.8, - TextWrapping = TextWrapping.Wrap, - Foreground = secondaryText - }); - } - headerGrid.Children.Add(headerText); - Grid.SetColumn(caret, 1); - headerGrid.Children.Add(caret); - - var headerBorder = new Border - { - Background = launcherBackground, - BorderBrush = BrushFromHex(accentHex), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Cursor = Cursors.Hand, - Child = headerGrid - }; - - void Toggle() - { - var nextExpanded = bodyBorder.Visibility != Visibility.Visible; - bodyBorder.Visibility = nextExpanded ? Visibility.Visible : Visibility.Collapsed; - caret.Text = nextExpanded ? "\uE70D" : "\uE76C"; - _overlaySectionExpandedStates[key] = nextExpanded; - } - - headerBorder.MouseLeftButtonUp += (_, _) => Toggle(); - - return new Border - { - Background = itemBackground, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(10), - Margin = new Thickness(0, 0, 0, 8), - Child = new StackPanel - { - Children = - { - headerBorder, - bodyBorder - } - } - }; - } - - private static TextBlock CreateOverlayPlaceholder(string text, Brush foreground, string? currentValue) - { - return new TextBlock - { - Text = text, - FontSize = 13, - Foreground = foreground, - Opacity = 0.45, - IsHitTestVisible = false, - VerticalAlignment = VerticalAlignment.Center, - Padding = new Thickness(14, 8, 14, 8), - Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed, - }; - } - - private void ShowOverlayHookEditDialog(AgentHookEntry? existing, int index) - { - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? BrushFromHex("#FFFFFF"); - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - - var isNew = existing == null; - var dlg = new Window - { - Title = isNew ? "훅 추가" : "훅 편집", - Width = 420, - SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, - ResizeMode = ResizeMode.NoResize, - WindowStyle = WindowStyle.None, - AllowsTransparency = true, - Background = Brushes.Transparent, - ShowInTaskbar = false - }; - - var border = new Border - { - Background = bgBrush, - CornerRadius = new CornerRadius(12), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(20) - }; - var stack = new StackPanel(); - stack.Children.Add(new TextBlock - { - Text = isNew ? "훅 추가" : "훅 편집", - FontSize = 15, - FontWeight = FontWeights.SemiBold, - Foreground = fgBrush, - Margin = new Thickness(0, 0, 0, 14) - }); - - dlg.KeyDown += (_, e) => { if (e.Key == Key.Escape) dlg.Close(); }; - - stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) }); - var nameBox = new TextBox - { - Text = existing?.Name ?? "", - FontSize = 13, - Foreground = fgBrush, - Background = itemBg, - BorderBrush = borderBrush, - Padding = new Thickness(12, 8, 12, 8) - }; - var nameHolder = CreateOverlayPlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name); - nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed; - var nameGrid = new Grid(); - nameGrid.Children.Add(nameBox); - nameGrid.Children.Add(nameHolder); - stack.Children.Add(nameGrid); - - stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); - var toolBox = new TextBox - { - Text = existing?.ToolName ?? "*", - FontSize = 13, - Foreground = fgBrush, - Background = itemBg, - BorderBrush = borderBrush, - Padding = new Thickness(12, 8, 12, 8) - }; - var toolHolder = CreateOverlayPlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*"); - toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed; - var toolGrid = new Grid(); - toolGrid.Children.Add(toolBox); - toolGrid.Children.Add(toolHolder); - stack.Children.Add(toolGrid); - - stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); - var timingPanel = new StackPanel { Orientation = Orientation.Horizontal }; - var preRadio = new RadioButton - { - Content = "Pre (실행 전)", - Foreground = fgBrush, - FontSize = 13, - Margin = new Thickness(0, 0, 16, 0), - IsChecked = (existing?.Timing ?? "post") == "pre" - }; - var postRadio = new RadioButton - { - Content = "Post (실행 후)", - Foreground = fgBrush, - FontSize = 13, - IsChecked = (existing?.Timing ?? "post") != "pre" - }; - timingPanel.Children.Add(preRadio); - timingPanel.Children.Add(postRadio); - stack.Children.Add(timingPanel); - - stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); - var pathGrid = new Grid(); - pathGrid.ColumnDefinitions.Add(new ColumnDefinition()); - pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - var pathInnerGrid = new Grid(); - var pathBox = new TextBox - { - Text = existing?.ScriptPath ?? "", - FontSize = 13, - Foreground = fgBrush, - Background = itemBg, - BorderBrush = borderBrush, - Padding = new Thickness(12, 8, 12, 8) - }; - var pathHolder = CreateOverlayPlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath); - pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed; - pathInnerGrid.Children.Add(pathBox); - pathInnerGrid.Children.Add(pathHolder); - pathGrid.Children.Add(pathInnerGrid); - - var browseBtn = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(10, 6, 10, 6), - Margin = new Thickness(6, 0, 0, 0), - Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = "...", - FontSize = 13, - Foreground = accentBrush - } - }; - browseBtn.MouseLeftButtonUp += (_, _) => - { - var ofd = new OpenFileDialog - { - Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*", - Title = "훅 스크립트 선택", - }; - if (ofd.ShowDialog() == true) - pathBox.Text = ofd.FileName; - }; - Grid.SetColumn(browseBtn, 1); - pathGrid.Children.Add(browseBtn); - stack.Children.Add(pathGrid); - - stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); - var argsBox = new TextBox - { - Text = existing?.Arguments ?? "", - FontSize = 13, - Foreground = fgBrush, - Background = itemBg, - BorderBrush = borderBrush, - Padding = new Thickness(12, 8, 12, 8) - }; - var argsHolder = CreateOverlayPlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments); - argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed; - var argsGrid = new Grid(); - argsGrid.Children.Add(argsBox); - argsGrid.Children.Add(argsHolder); - stack.Children.Add(argsGrid); - - var btnRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 16, 0, 0) - }; - var cancelBorder = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(16, 8, 16, 8), - Margin = new Thickness(0, 0, 8, 0), - Cursor = Cursors.Hand, - Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush } - }; - cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close(); - btnRow.Children.Add(cancelBorder); - - var saveBorder = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(16, 8, 16, 8), - Cursor = Cursors.Hand, - Child = new TextBlock - { - Text = isNew ? "추가" : "저장", - FontSize = 13, - Foreground = Brushes.White, - FontWeight = FontWeights.SemiBold - } - }; - saveBorder.MouseLeftButtonUp += (_, _) => - { - if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text)) - { - CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - var entry = new AgentHookEntry - { - Name = nameBox.Text.Trim(), - ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(), - Timing = preRadio.IsChecked == true ? "pre" : "post", - ScriptPath = pathBox.Text.Trim(), - Arguments = argsBox.Text.Trim(), - Enabled = existing?.Enabled ?? true, - }; - - var hooks = _settings.Settings.Llm.AgentHooks; - if (isNew) - hooks.Add(entry); - else if (index >= 0 && index < hooks.Count) - hooks[index] = entry; - - BuildOverlayHookCards(); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - dlg.Close(); - }; - btnRow.Children.Add(saveBorder); - stack.Children.Add(btnRow); - - border.Child = stack; - dlg.Content = border; - dlg.ShowDialog(); - } - - private static string GetOverlayToolCategory(string toolName) - { - return toolName switch - { - "file_read" or "file_write" or "file_edit" or "glob" or "grep_tool" or "folder_map" or "document_read" or "file_manage" or "file_info" or "multi_read" - => "파일/검색", - "process" or "build_run" or "dev_env_detect" or "snippet_runner" - => "프로세스/빌드", - "search_codebase" or "code_search" or "code_review" or "lsp" or "test_loop" or "git_tool" or "project_rules" or "project_rule" or "diff_preview" - => "코드 분석", - "excel_create" or "docx_create" or "csv_create" or "markdown_create" or "html_create" or "chart_create" or "batch_create" or "pptx_create" or "document_review" or "format_convert" or "document_planner" or "document_assembler" or "template_render" - => "문서 생성", - "json_tool" or "regex_tool" or "diff_tool" or "base64_tool" or "hash_tool" or "datetime_tool" or "math_tool" or "xml_tool" or "sql_tool" or "data_pivot" or "text_summarize" - => "데이터 처리", - "clipboard_tool" or "notify_tool" or "env_tool" or "zip_tool" or "http_tool" or "open_external" or "image_analyze" or "file_watch" - => "시스템/환경", - "spawn_agent" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook" - => "에이전트", - _ => "기타" - }; - } - - private void CmbOverlayService_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service) - return; - - var llm = _settings.Settings.Llm; - llm.Service = service; - var candidates = GetModelCandidates(service); - var preferredModel = service switch - { - "ollama" => llm.OllamaModel, - "vllm" => llm.VllmModel, - "gemini" => llm.GeminiModel, - _ => llm.ClaudeModel - }; - if (!string.IsNullOrWhiteSpace(preferredModel)) - llm.Model = preferredModel; - else if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model)) - llm.Model = candidates[0].Id; - - PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); - } - - private void CmbOverlayModel_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId) - return; - - CommitOverlayModelSelection(modelId); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void RefreshOverlayThemeCards() - { - var selected = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant(); - SetOverlayCardSelection(OverlayThemeSystemCard, selected == "system"); - SetOverlayCardSelection(OverlayThemeLightCard, selected == "light"); - SetOverlayCardSelection(OverlayThemeDarkCard, selected == "dark"); - var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claude").ToLowerInvariant(); - SetOverlayCardSelection(OverlayThemeStyleClawCard, preset is "claw" or "claude"); - SetOverlayCardSelection(OverlayThemeStyleCodexCard, preset == "codex"); - SetOverlayCardSelection(OverlayThemeStyleNordCard, preset == "nord"); - SetOverlayCardSelection(OverlayThemeStyleEmberCard, preset == "ember"); - SetOverlayCardSelection(OverlayThemeStyleSlateCard, preset == "slate"); - } - - private void RefreshOverlayServiceCards() - { - var service = (_settings.Settings.Llm.Service ?? "ollama").ToLowerInvariant(); - SetOverlayCardSelection(OverlaySvcOllamaCard, service == "ollama"); - SetOverlayCardSelection(OverlaySvcVllmCard, service == "vllm"); - SetOverlayCardSelection(OverlaySvcGeminiCard, service == "gemini"); - SetOverlayCardSelection(OverlaySvcClaudeCard, service is "claude" or "sigmoid"); - } - - private void RefreshOverlayTokenPresetCards() - { - var llm = _settings.Settings.Llm; - var compact = llm.ContextCompactTriggerPercent switch - { - <= 60 => 60, - <= 70 => 70, - <= 80 => 80, - _ => 90 - }; - SetOverlayCardSelection(OverlayCompact60Card, compact == 60); - SetOverlayCardSelection(OverlayCompact70Card, compact == 70); - SetOverlayCardSelection(OverlayCompact80Card, compact == 80); - SetOverlayCardSelection(OverlayCompact90Card, compact == 90); - - var context = llm.MaxContextTokens switch - { - <= 4096 => 4096, - <= 16384 => 16384, - <= 32768 => 32768, - <= 65536 => 65536, - <= 131072 => 131072, - <= 262144 => 262144, - _ => 1_000_000 - }; - SetOverlayCardSelection(OverlayContext4KCard, context == 4096); - SetOverlayCardSelection(OverlayContext16KCard, context == 16384); - SetOverlayCardSelection(OverlayContext32KCard, context == 32768); - SetOverlayCardSelection(OverlayContext64KCard, context == 65536); - SetOverlayCardSelection(OverlayContext128KCard, context == 131072); - SetOverlayCardSelection(OverlayContext256KCard, context == 262144); - SetOverlayCardSelection(OverlayContext1MCard, context == 1_000_000); - } - - private void RefreshOverlayModeButtons() - { - var llm = _settings.Settings.Llm; - SelectComboTag(CmbOverlayOperationMode, OperationModePolicy.Normalize(_settings.Settings.OperationMode)); - SelectComboTag(CmbOverlayPermission, PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission)); - SelectComboTag(CmbOverlayReasoning, llm.AgentDecisionLevel); - SelectComboTag(CmbOverlayFastMode, llm.FreeTierMode ? "on" : "off"); - // CmbOverlayDefaultOutputFormat, CmbOverlayDefaultMood, CmbOverlayAutoPreview 제거됨 (중복 설정 항목) - SelectComboTag(CmbOverlayAgentLogLevel, llm.AgentLogLevel ?? "simple"); - UpdateDataUsageUI(); - RefreshOverlayTemperatureModeButtons(); - } - - private void RefreshOverlayTemperatureModeButtons() - { - if (OverlayTemperatureAutoCard == null || OverlayTemperatureCustomCard == null) - return; - - var automatic = _settings.Settings.Llm.UseAutomaticProfileTemperature; - SetOverlayCardSelection(OverlayTemperatureAutoCard, automatic); - SetOverlayCardSelection(OverlayTemperatureCustomCard, !automatic); - - if (SldOverlayTemperature != null) - { - SldOverlayTemperature.IsEnabled = !automatic; - SldOverlayTemperature.Opacity = automatic ? 0.55 : 1.0; - } - - if (TxtOverlayTemperatureValue != null) - TxtOverlayTemperatureValue.Opacity = automatic ? 0.65 : 1.0; - } - - private static void SelectComboTag(ComboBox? combo, string? tag) - { - if (combo == null) return; - var normalized = (tag ?? "").Trim(); - combo.SelectedItem = combo.Items - .OfType() - .FirstOrDefault(item => string.Equals(item.Tag as string, normalized, StringComparison.OrdinalIgnoreCase)); - } - - private void PopulateOverlayMoodCombo() - { - // CmbOverlayDefaultMood 제거됨 (중복 설정 항목) - } - - private static string GetQuickActionLabel(string title, string value) - => $"{title} · {value}"; - - private void RefreshOverlayServiceFieldLabels(string service) - { - if (OverlayEndpointLabel == null || OverlayEndpointHint == null || OverlayApiKeyLabel == null || OverlayApiKeyHint == null) - return; - - switch (service) - { - case "ollama": - OverlayEndpointLabel.Text = "Ollama 서버 주소"; - OverlayEndpointHint.Text = "사내 로컬 Ollama 기본 주소를 입력합니다."; - OverlayApiKeyLabel.Text = "Ollama API 키"; - OverlayApiKeyHint.Text = "사내 게이트웨이를 쓰는 경우에만 입력합니다."; - break; - case "vllm": - OverlayEndpointLabel.Text = "vLLM 서버 주소"; - OverlayEndpointHint.Text = "OpenAI 호환 엔드포인트 주소를 입력합니다."; - OverlayApiKeyLabel.Text = "vLLM API 키"; - OverlayApiKeyHint.Text = "사내 인증 게이트웨이를 쓰는 경우에만 입력합니다."; - break; - case "gemini": - OverlayEndpointLabel.Text = "기본 서버 주소"; - OverlayEndpointHint.Text = "Gemini는 내부 기본 주소를 사용합니다."; - OverlayApiKeyLabel.Text = "Gemini API 키"; - OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다."; - break; - default: - OverlayEndpointLabel.Text = "기본 서버 주소"; - OverlayEndpointHint.Text = "Claude는 내부 기본 주소를 사용합니다."; - OverlayApiKeyLabel.Text = "Claude API 키"; - OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다."; - break; - } - } - - private void RefreshOverlayServiceFieldVisibility(string service) - { - if (OverlayEndpointFieldPanel == null || OverlayApiKeyFieldPanel == null) - return; - - var hideEndpoint = string.Equals(service, "gemini", StringComparison.OrdinalIgnoreCase) - || string.Equals(service, "claude", StringComparison.OrdinalIgnoreCase); - - OverlayEndpointFieldPanel.Visibility = hideEndpoint ? Visibility.Collapsed : Visibility.Visible; - OverlayApiKeyFieldPanel.Margin = hideEndpoint ? new Thickness(0) : new Thickness(6, 0, 0, 0); - Grid.SetColumn(OverlayApiKeyFieldPanel, hideEndpoint ? 0 : 1); - Grid.SetColumnSpan(OverlayApiKeyFieldPanel, hideEndpoint ? 2 : 1); - } - - private string GetOverlayServiceEndpoint(string service) - { - var llm = _settings.Settings.Llm; - return service switch - { - "ollama" => llm.OllamaEndpoint ?? "", - "vllm" => llm.VllmEndpoint ?? "", - "gemini" => llm.Endpoint ?? "", - "claude" or "sigmoid" => llm.Endpoint ?? "", - _ => llm.Endpoint ?? "" - }; - } - - private string GetOverlayServiceApiKey(string service) - { - var llm = _settings.Settings.Llm; - return service switch - { - "ollama" => llm.OllamaApiKey ?? "", - "vllm" => llm.VllmApiKey ?? "", - "gemini" => llm.GeminiApiKey ?? "", - "claude" or "sigmoid" => llm.ClaudeApiKey ?? "", - _ => llm.ApiKey ?? "" - }; - } - - private void BuildOverlayRegisteredModelsPanel(string service) - { - if (OverlayRegisteredModelsPanel == null || OverlayRegisteredModelsHeader == null || BtnOverlayAddModel == null) - return; - - var normalized = NormalizeOverlayService(service); - var supportsRegistered = SupportsOverlayRegisteredModels(normalized); - OverlayRegisteredModelsHeader.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; - OverlayRegisteredModelsPanel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; - BtnOverlayAddModel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; - - OverlayRegisteredModelsPanel.Children.Clear(); - if (!supportsRegistered) - return; - - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.White; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - - var models = _settings.Settings.Llm.RegisteredModels - .Where(m => string.Equals(m.Service, normalized, StringComparison.OrdinalIgnoreCase)) - .OrderBy(m => m.Alias, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (models.Count == 0) - { - OverlayRegisteredModelsPanel.Children.Add(new Border - { - CornerRadius = new CornerRadius(10), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Background = Brushes.Transparent, - Padding = new Thickness(12, 10, 12, 10), - Child = new TextBlock - { - Text = "등록된 모델이 없습니다. `모델 추가`로 사내 모델을 먼저 등록하세요.", - FontSize = 11.5, - TextWrapping = TextWrapping.Wrap, - Foreground = secondaryText, - } - }); - return; - } - - foreach (var model in models) - { - var decryptedModelName = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled); - var displayName = string.IsNullOrWhiteSpace(model.Alias) ? decryptedModelName : model.Alias; - var endpointText = string.IsNullOrWhiteSpace(model.Endpoint) ? "기본 서버 사용" : model.Endpoint; - var authLabel = (model.AuthType ?? "bearer").ToLowerInvariant() switch - { - "cp4d" => "CP4D", - "ibm_iam" => "IBM IAM", - _ => "Bearer", - }; - var isActive = string.Equals(model.EncryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase) - || string.Equals(decryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase); - - var row = new Border - { - CornerRadius = new CornerRadius(10), - BorderBrush = isActive ? accentBrush : borderBrush, - BorderThickness = new Thickness(1), - Background = isActive ? (TryFindResource("HintBackground") as Brush ?? BrushFromHex("#EFF6FF")) : itemBg, - Padding = new Thickness(12, 10, 12, 10), - Margin = new Thickness(0, 0, 0, 8), - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var info = new StackPanel(); - info.Children.Add(new TextBlock - { - Text = displayName, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - }); - info.Children.Add(new TextBlock - { - Text = string.IsNullOrWhiteSpace(decryptedModelName) ? "(모델명 없음)" : decryptedModelName, - Margin = new Thickness(0, 3, 0, 0), - FontSize = 11, - Foreground = secondaryText, - }); - info.Children.Add(new TextBlock - { - Text = $"엔드포인트: {endpointText} · 인증: {authLabel}", - Margin = new Thickness(0, 4, 0, 0), - FontSize = 10.5, - TextWrapping = TextWrapping.Wrap, - Foreground = secondaryText, - }); - Grid.SetColumn(info, 0); - grid.Children.Add(info); - - var actions = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Top, - }; - - Border CreateAction(string text, Action onClick, Brush foreground) - { - var label = new TextBlock - { - Text = text, - FontSize = 11.5, - FontWeight = FontWeights.SemiBold, - Foreground = foreground, - VerticalAlignment = VerticalAlignment.Center, - }; - - var action = new Border - { - Cursor = Cursors.Hand, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(6, 0, 0, 0), - Background = Brushes.Transparent, - Child = label, - }; - action.MouseEnter += (_, _) => - { - action.Background = hoverBg; - }; - action.MouseLeave += (_, _) => - { - action.Background = Brushes.Transparent; - }; - action.MouseLeftButtonUp += (_, _) => onClick(); - return action; - } - - actions.Children.Add(CreateAction("선택", () => - { - CommitOverlayModelSelection(model.EncryptedModelName); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - }, accentBrush)); - - actions.Children.Add(CreateAction("편집", () => - { - EditOverlayRegisteredModel(model); - BuildOverlayRegisteredModelsPanel(service); - }, primaryText)); - actions.Children.Add(CreateAction("삭제", () => - { - DeleteOverlayRegisteredModel(model); - }, BrushFromHex("#DC2626"))); - - Grid.SetColumn(actions, 1); - grid.Children.Add(actions); - - row.Child = grid; - row.MouseEnter += (_, _) => - { - if (!isActive) - row.Background = hoverBg; - }; - row.MouseLeave += (_, _) => - { - if (!isActive) - row.Background = itemBg; - }; - - OverlayRegisteredModelsPanel.Children.Add(row); - } - } - - private void BtnOverlayAddModel_Click(object sender, RoutedEventArgs e) - { - var service = NormalizeOverlayService(_settings.Settings.Llm.Service); - if (!SupportsOverlayRegisteredModels(service)) - return; - - var dlg = new ModelRegistrationDialog(service) { Owner = this }; - if (dlg.ShowDialog() != true) - return; - - _settings.Settings.Llm.RegisteredModels.Add(new RegisteredModel - { - Alias = dlg.ModelAlias, - EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled), - Service = service, - ExecutionProfile = dlg.ExecutionProfile, - Endpoint = dlg.Endpoint, - ApiKey = dlg.ApiKey, - AllowInsecureTls = dlg.AllowInsecureTls, - AuthType = dlg.AuthType, - Cp4dUrl = dlg.Cp4dUrl, - Cp4dUsername = dlg.Cp4dUsername, - Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled), - }); - - PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); - } - - private void EditOverlayRegisteredModel(RegisteredModel model) - { - var currentModel = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled); - var cp4dPassword = Services.CryptoService.DecryptIfEnabled(model.Cp4dPassword ?? "", IsOverlayEncryptionEnabled); - var service = NormalizeOverlayService(model.Service); - - var dlg = new ModelRegistrationDialog( - service, - model.Alias, - currentModel, - model.Endpoint, - model.ApiKey, - model.AllowInsecureTls, - model.AuthType ?? "bearer", - model.Cp4dUrl ?? "", - model.Cp4dUsername ?? "", - cp4dPassword, - model.ExecutionProfile ?? "balanced") - { Owner = this }; - - if (dlg.ShowDialog() != true) - return; - - model.Alias = dlg.ModelAlias; - model.EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled); - model.Service = service; - model.ExecutionProfile = dlg.ExecutionProfile; - model.Endpoint = dlg.Endpoint; - model.ApiKey = dlg.ApiKey; - model.AllowInsecureTls = dlg.AllowInsecureTls; - model.AuthType = dlg.AuthType; - model.Cp4dUrl = dlg.Cp4dUrl; - model.Cp4dUsername = dlg.Cp4dUsername; - model.Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled); - - PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); - } - - private void DeleteOverlayRegisteredModel(RegisteredModel model) - { - var result = CustomMessageBox.Show($"'{model.Alias}' 모델을 삭제하시겠습니까?", "모델 삭제", - MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result != MessageBoxResult.Yes) - return; - - _settings.Settings.Llm.RegisteredModels.Remove(model); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); - } - - private void SetOverlayCardSelection(Border border, bool selected) - { - var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - border.BorderBrush = selected ? accent : normal; - border.Background = selected - ? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent) - : Brushes.Transparent; - } - - private void OverlayThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentTheme = "system"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentTheme = "light"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentTheme = "dark"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeStyleClawCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentThemePreset = "claude"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeStyleCodexCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentThemePreset = "codex"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeStyleNordCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentThemePreset = "nord"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeStyleEmberCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentThemePreset = "ember"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlayThemeStyleSlateCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - _settings.Settings.Llm.AgentThemePreset = "slate"; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void OverlaySvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("ollama"); - private void OverlaySvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("vllm"); - private void OverlaySvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("gemini"); - private void OverlaySvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("claude"); - - private void SetOverlayService(string service) - { - _settings.Settings.Llm.Service = service; - var llm = _settings.Settings.Llm; - var candidates = GetModelCandidates(service); - var preferredModel = service switch - { - "ollama" => llm.OllamaModel, - "vllm" => llm.VllmModel, - "gemini" => llm.GeminiModel, - _ => llm.ClaudeModel - }; - llm.Model = !string.IsNullOrWhiteSpace(preferredModel) - ? preferredModel - : candidates.FirstOrDefault().Id ?? llm.Model; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); - RefreshOverlayVisualState(loadDeferredInputs: true); - } - - private void BtnOverlayOperationMode_Click(object sender, RoutedEventArgs e) - { - var next = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode - ? OperationModePolicy.InternalMode - : OperationModePolicy.ExternalMode; - - if (!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요.")) - return; - - _settings.Settings.OperationMode = next; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void BtnOverlayFolderDataUsage_Click(object sender, RoutedEventArgs e) - { - _folderDataUsage = GetAutomaticFolderDataUsage(); - } - - private void CmbOverlayFastMode_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayFastMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) - return; - - _settings.Settings.Llm.FreeTierMode = string.Equals(tag, "on", StringComparison.OrdinalIgnoreCase); - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void CmbOverlayReasoning_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayReasoning.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) - return; - - _settings.Settings.Llm.AgentDecisionLevel = tag; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void CmbOverlayPermission_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayPermission.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) - return; - - var normalized = PermissionModeCatalog.NormalizeGlobalMode(tag); - var llm = _settings.Settings.Llm; - llm.FilePermission = normalized; - llm.DefaultAgentPermission = normalized; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void CmbOverlayDefaultOutputFormat_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // 제거됨 (중복 설정 항목) - } - - private void CmbOverlayDefaultMood_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // 제거됨 (중복 설정 항목) - } - - private void CmbOverlayAutoPreview_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // 제거됨 (중복 설정 항목) - } - - private void CmbOverlayOperationMode_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayOperationMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) - return; - - var normalized = OperationModePolicy.Normalize(tag); - var current = OperationModePolicy.Normalize(_settings.Settings.OperationMode); - if (string.Equals(normalized, current, StringComparison.OrdinalIgnoreCase)) - return; - - if (string.Equals(normalized, OperationModePolicy.ExternalMode, StringComparison.OrdinalIgnoreCase) && - !PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요.")) - { - RefreshOverlayModeButtons(); - return; - } - - _settings.Settings.OperationMode = normalized; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void CmbOverlayFolderDataUsage_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - _folderDataUsage = GetAutomaticFolderDataUsage(); - } - - private void CmbOverlayAgentLogLevel_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isOverlaySettingsSyncing || CmbOverlayAgentLogLevel.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) - return; - - _settings.Settings.Llm.AgentLogLevel = tag; - PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); - } - - private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e) - { - if (System.Windows.Application.Current is App app) - app.OpenSettingsFromChat(); - } - - private bool PromptOverlayPasswordDialog(string title, string header, string message) - { - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; - - var dlg = new Window - { - Title = title, - Width = 340, - SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, - ResizeMode = ResizeMode.NoResize, - WindowStyle = WindowStyle.None, - AllowsTransparency = true, - Background = Brushes.Transparent, - ShowInTaskbar = false, - }; - - var border = new Border - { - Background = bgBrush, - CornerRadius = new CornerRadius(12), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(20), - }; - - var stack = new StackPanel(); - stack.Children.Add(new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12) }); - stack.Children.Add(new TextBlock { Text = message, FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6) }); - - var pwBox = new PasswordBox - { - FontSize = 14, - Padding = new Thickness(8, 6, 8, 6), - Background = itemBg, - Foreground = fgBrush, - BorderBrush = borderBrush, - PasswordChar = '*', - }; - stack.Children.Add(pwBox); - - var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) }; - var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) }; - cancelBtn.Click += (_, _) => dlg.DialogResult = false; - btnRow.Children.Add(cancelBtn); - - var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true }; - okBtn.Click += (_, _) => - { - if (pwBox.Password == UnifiedAdminPassword) - dlg.DialogResult = true; - else - { - pwBox.Clear(); - pwBox.Focus(); - } - }; - btnRow.Children.Add(okBtn); - stack.Children.Add(btnRow); - - border.Child = stack; - dlg.Content = border; - dlg.Loaded += (_, _) => pwBox.Focus(); - return dlg.ShowDialog() == true; - } - - private static int ParseOverlayInt(string? text, int fallback, int min, int max) - { - if (!int.TryParse(text, out var value)) - value = fallback; - return Math.Clamp(value, min, max); - } - - private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e) - => InlineSettingsPanel.IsOpen = false; - - private void CmbInlineService_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isInlineSettingsSyncing || CmbInlineService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service) - return; - - var llm = _settings.Settings.Llm; - llm.Service = service; - - var candidates = GetModelCandidates(service); - if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model)) - llm.Model = candidates[0].Id; - - _settings.Save(); - _appState.LoadFromSettings(_settings); - UpdateModelLabel(); - RefreshInlineSettingsPanel(); - } - - private void CmbInlineModel_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_isInlineSettingsSyncing || CmbInlineModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId) - return; - - _settings.Settings.Llm.Model = modelId; - _settings.Save(); - _appState.LoadFromSettings(_settings); - UpdateModelLabel(); - RefreshInlineSettingsPanel(); - } - - private void BtnInlineFastMode_Click(object sender, RoutedEventArgs e) - { - _settings.Settings.Llm.FreeTierMode = !_settings.Settings.Llm.FreeTierMode; - _settings.Save(); - _appState.LoadFromSettings(_settings); - RefreshInlineSettingsPanel(); - RefreshOverlayVisualState(loadDeferredInputs: false); - } - - private void BtnInlineReasoning_Click(object sender, RoutedEventArgs e) - { - var llm = _settings.Settings.Llm; - llm.AgentDecisionLevel = NextReasoning(llm.AgentDecisionLevel); - _settings.Save(); - _appState.LoadFromSettings(_settings); - RefreshInlineSettingsPanel(); - RefreshOverlayVisualState(loadDeferredInputs: false); - } - - private void BtnInlinePermission_Click(object sender, RoutedEventArgs e) - { - var llm = _settings.Settings.Llm; - llm.FilePermission = NextPermission(llm.FilePermission); - _settings.Save(); - _appState.LoadFromSettings(_settings); - UpdatePermissionUI(); - SaveConversationSettings(); - RefreshInlineSettingsPanel(); - RefreshOverlayVisualState(loadDeferredInputs: false); - } - - private void BtnInlineSkill_Click(object sender, RoutedEventArgs e) - { - var llm = _settings.Settings.Llm; - llm.EnableSkillSystem = !llm.EnableSkillSystem; - if (llm.EnableSkillSystem) - { - SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(llm.SkillsFolderPath); - UpdateConditionalSkillActivation(reset: true); - } - - _settings.Save(); - _appState.LoadFromSettings(_settings); - RefreshInlineSettingsPanel(); - - if (llm.EnableSkillSystem) - OpenCommandSkillBrowser("/"); - } - - private void BtnInlineCommandBrowser_Click(object sender, RoutedEventArgs e) - => OpenCommandSkillBrowser("/"); - - private void BtnInlineMcp_Click(object sender, RoutedEventArgs e) - { - var app = System.Windows.Application.Current as App; - app?.OpenSettingsFromChat(); - } - - private void BtnNewChat_Click(object sender, RoutedEventArgs e) - { - StartNewConversation(); - InputBox.Focus(); - } - - public void ResumeConversation(string conversationId) - { - var conv = _storage.Load(conversationId); - if (conv != null) - { - var targetTab = NormalizeTabName(conv.Tab); - if (!string.Equals(_activeTab, targetTab, StringComparison.OrdinalIgnoreCase)) - { - SaveCurrentTabConversationId(); - PersistPerTabUiState(); - _activeTab = targetTab; - RestorePerTabUiState(); - UpdateTabUI(); - - if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase)) - TabChat.IsChecked = true; - else if (string.Equals(targetTab, "Cowork", StringComparison.OrdinalIgnoreCase)) - TabCowork.IsChecked = true; - else if (TabCode.IsEnabled) - TabCode.IsChecked = true; - } - - lock (_convLock) - { - conv.Tab = targetTab; - _currentConversation = ChatSession?.SetCurrentConversation(targetTab, conv, _storage) ?? conv; - SyncTabConversationIdsFromSession(); - } - SaveLastConversations(); - UpdateChatTitle(); - RefreshConversationList(); - RenderMessages(); - UpdateFolderBar(); - RefreshDraftQueueUi(); - } - InputBox.Focus(); - } - - private static string NormalizeTabName(string? tab) - { - var normalized = (tab ?? "").Trim(); - if (string.IsNullOrEmpty(normalized)) - return "Chat"; - - if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase)) - return "Cowork"; - - var canonical = new string(normalized - .Where(char.IsLetterOrDigit) - .ToArray()) - .ToLowerInvariant(); - - if (canonical is "cowork" or "coworkcode" or "coworkcodetab") - return "Cowork"; - - if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase) - || canonical is "code" or "codetab") - return "Code"; - - return "Chat"; - } - - public void StartNewAndFocus() - { - StartNewConversation(); - InputBox.Focus(); - } - - private void BtnDeleteAll_Click(object sender, RoutedEventArgs e) - { - var tabLabel = _activeTab switch - { - "Cowork" => "코워크", - "Code" => "코드", - _ => "채팅" - }; - var result = CustomMessageBox.Show( - $"'{tabLabel}' 탭의 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", - $"{tabLabel} 대화 전체 삭제", - MessageBoxButton.YesNo, - MessageBoxImage.Warning); - - if (result != MessageBoxResult.Yes) return; - - _storage.DeleteAllByTab(_activeTab); - lock (_convLock) - { - ChatSession?.ClearCurrentConversation(_activeTab); - _currentConversation = null; - SyncTabConversationIdsFromSession(); - } - ClearTranscriptElements(); - EmptyState.Visibility = Visibility.Visible; - UpdateChatTitle(); - RefreshConversationList(); - } // ─── 미리보기 패널 (탭 기반) ───────────────────────────────────────────── @@ -15954,7 +7509,7 @@ public partial class ChatWindow : Window new TextBlock { Text = "\uE756", - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 9, Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"), Margin = new Thickness(0, 0, 4, 0), @@ -16038,7 +7593,7 @@ public partial class ChatWindow : Window new TextBlock { Text = "\uE9F9", - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 9, Foreground = BrushFromHex("#1D4ED8"), Margin = new Thickness(0, 0, 4, 0), @@ -16092,7 +7647,7 @@ public partial class ChatWindow : Window new TextBlock { Text = "\uE823", - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 9, Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"), Margin = new Thickness(0, 0, 4, 0), @@ -16332,16 +7887,20 @@ public partial class ChatWindow : Window AddTaskSummaryBackgroundSection(panel); } - private static readonly Dictionary _brushCache = new(StringComparer.OrdinalIgnoreCase); + // ─── 공유 정적 리소스 (FontFamily/Brush 반복 할당 방지) ────────────────── + private static readonly FontFamily s_segoeIconFont = new("Segoe MDL2 Assets"); + private static readonly FontFamily s_segoeUiFont = new("Segoe UI, Malgun Gothic"); + + private static readonly System.Collections.Concurrent.ConcurrentDictionary _brushCache = new(StringComparer.OrdinalIgnoreCase); private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) { - if (_brushCache.TryGetValue(hex, out var cached)) - return cached; - var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!; - var brush = new System.Windows.Media.SolidColorBrush(c); - brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화 - _brushCache[hex] = brush; - return brush; + return _brushCache.GetOrAdd(hex, static h => + { + var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(h)!; + var brush = new System.Windows.Media.SolidColorBrush(c); + brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화 + return brush; + }); } }