diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index ec8ec57..750edde 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4659,5 +4659,21 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~42 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 10차) +## Phase 43 — 4개 대형 파일 파셜 분할 (v2.3) ✅ 완료 + +> **목표**: SettingsWindow.AgentConfig·ChatWindow.Presets·PreviewAndFiles·WorkflowAnalyzerWindow 동시 분할. + +| 원본 파일 | 원본 | 메인 | 신규 파일 | 신규 줄 수 | +|----------|------|------|----------|----------| +| SettingsWindow.AgentConfig.cs | 1,202 | 608 | AgentHooks.cs | 605 | +| ChatWindow.Presets.cs | 1,280 | 315 | CustomPresets.cs | 978 | +| ChatWindow.PreviewAndFiles.cs | 1,105 | 709 | FileBrowser.cs | 408 | +| WorkflowAnalyzerWindow.xaml.cs | 929 | 274 | Charts.cs | 667 | + +- **총 신규 파일**: 4개 +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~43 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 11차) diff --git a/src/AxCopilot/Views/ChatWindow.CustomPresets.cs b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs new file mode 100644 index 0000000..860ad2e --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs @@ -0,0 +1,978 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 커스텀 프리셋 관리 ───────────────────────────────────────────── + + /// 커스텀 프리셋 추가 다이얼로그를 표시합니다. + private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null) + { + bool isEdit = existing != null; + var dlg = new CustomPresetDialog( + existingName: existing?.Label ?? "", + existingDesc: existing?.Description ?? "", + existingPrompt: existing?.SystemPrompt ?? "", + existingColor: existing?.Color ?? "#6366F1", + existingSymbol: existing?.Symbol ?? "\uE713", + existingTab: existing?.Tab ?? _activeTab) + { + Owner = this, + }; + + if (dlg.ShowDialog() == true) + { + if (isEdit) + { + existing!.Label = dlg.PresetName; + existing.Description = dlg.PresetDescription; + existing.SystemPrompt = dlg.PresetSystemPrompt; + existing.Color = dlg.PresetColor; + existing.Symbol = dlg.PresetSymbol; + existing.Tab = dlg.PresetTab; + } + else + { + Llm.CustomPresets.Add(new Models.CustomPresetEntry + { + Label = dlg.PresetName, + Description = dlg.PresetDescription, + SystemPrompt = dlg.PresetSystemPrompt, + Color = dlg.PresetColor, + Symbol = dlg.PresetSymbol, + Tab = dlg.PresetTab, + }); + } + _settings.Save(); + BuildTopicButtons(); + } + } + + /// 커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다. + private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset) + { + if (anchor == null || preset.CustomId == null) return; + + var popup = new System.Windows.Controls.Primitives.Popup + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, + StaysOpen = false, + AllowsTransparency = true, + }; + + var menuBg = ThemeResourceHelper.Background(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var borderBrush = ThemeResourceHelper.Border(this); + + 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 = Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId); + if (entry != null) ShowCustomPresetDialog(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( + $"'{preset.Label}' 프리셋을 삭제하시겠습니까?", + "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId); + _settings.Save(); + BuildTopicButtons(); + } + }; + stack.Children.Add(deleteItem); + + menuBorder.Child = stack; + popup.Child = menuBorder; + popup.IsOpen = true; + } + + /// 컨텍스트 메뉴 항목을 생성합니다. + private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg) + { + var item = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 6, 14, 6), + Cursor = Cursors.Hand, + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, Foreground = fg, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, Foreground = fg, + VerticalAlignment = VerticalAlignment.Center, + }); + item.Child = sp; + + var hoverBg = ThemeResourceHelper.HoverBg(this); + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + + return item; + } + + /// 대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용. + private void SelectTopic(Services.TopicPreset preset) + { + bool hasMessages; + lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0; + + // 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존) + bool hasInput = !string.IsNullOrEmpty(InputBox.Text); + bool keepConversation = hasMessages || hasInput; + + if (!keepConversation) + { + // 메시지도 입력 텍스트도 없으면 새 대화 시작 + StartNewConversation(); + } + + // 프리셋 적용 (기존 대화에도 프리셋 변경 가능) + lock (_convLock) + { + if (_currentConversation != null) + { + _currentConversation.SystemCommand = preset.SystemPrompt; + _currentConversation.Category = preset.Category; + } + } + + if (!keepConversation) + EmptyState.Visibility = Visibility.Collapsed; + + InputBox.Focus(); + + if (!string.IsNullOrEmpty(preset.Placeholder)) + { + _promptCardPlaceholder = preset.Placeholder; + if (!keepConversation) ShowPlaceholder(); + } + + if (keepConversation) + ShowToast($"프리셋 변경: {preset.Label}"); + + // Cowork 탭: 하단 바 갱신 + if (_activeTab == "Cowork") + BuildBottomBar(); + } + + + + /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). + private string _selectedMood = null!; // Loaded 이벤트에서 초기화 + private string _selectedLanguage = "auto"; // Code 탭 개발 언어 + private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 + + /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼). + private void BuildBottomBar() + { + MoodIconPanel.Children.Clear(); + + var secondaryText = ThemeResourceHelper.Secondary(this); + + // ── 포맷 버튼 ── + var currentFormat = Llm.DefaultOutputFormat ?? "auto"; + var formatLabel = GetFormatLabel(currentFormat); + var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6"); + formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); }; + // Name 등록 (Popup PlacementTarget용) + try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(formatBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // ── 디자인 버튼 (소극 스타일) ── + var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood); + var moodLabel = currentMood?.Label ?? "모던"; + var moodIcon = currentMood?.Icon ?? "🔷"; + var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택"); + moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); }; + try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(moodBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // ── 파일 탐색기 토글 버튼 ── + var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); + fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; + MoodIconPanel.Children.Add(fileBrowserBtn); + + // ── 실행 이력 상세도 버튼 ── + AppendLogLevelButton(); + + // 구분선 표시 + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + } + + /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글. + private void BuildCodeBottomBar() + { + MoodIconPanel.Children.Clear(); + + var secondaryText = ThemeResourceHelper.Secondary(this); + + // 개발 언어 선택 버튼 + var langLabel = _selectedLanguage switch + { + "python" => "🐍 Python", + "java" => "☕ Java", + "csharp" => "🔷 C#", + "cpp" => "⚙ C++", + "javascript" => "🌐 JavaScript", + _ => "🔧 자동 감지", + }; + var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택"); + langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); }; + try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(langBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // 파일 탐색기 토글 + var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); + fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; + MoodIconPanel.Children.Add(fileBrowserBtn); + + // ── 실행 이력 상세도 버튼 ── + AppendLogLevelButton(); + + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + } + + /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. + private void AppendLogLevelButton() + { + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + var currentLevel = 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 (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(logBtn); + } + + /// 실행 이력 상세도 팝업 메뉴를 표시합니다. + private void ShowLogLevelMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = ThemeResourceHelper.Primary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + var levels = new (string Key, string Label, string Desc)[] + { + ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), + ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), + ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), + }; + + var current = 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 = ThemeResourceHelper.HoverBg(this); + item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; + item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; + item.MouseLeftButtonUp += (_, _) => + { + 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 (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + FormatMenuPopup.IsOpen = true; + } + + private void ShowLanguageMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = ThemeResourceHelper.Primary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + + 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 = ThemeResourceHelper.Secondary(this); + 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 = ThemeResourceHelper.SegoeMdl2, + 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, + }); + + return new Border + { + Child = sp, + Background = Brushes.Transparent, + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + ToolTip = tooltip, + }; + } + + + 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 = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var currentFormat = 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 = ThemeResourceHelper.SegoeMdl2, + 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; + Llm.DefaultOutputFormat = capturedKey; + _settings.Save(); + BuildBottomBar(); + }; + + FormatMenuItems.Children.Add(itemBorder); + } + + // PlacementTarget을 동적 등록된 버튼으로 설정 + if (FindName("BtnFormatMenu") is UIElement formatTarget) + FormatMenuPopup.PlacementTarget = formatTarget; + FormatMenuPopup.IsOpen = true; + } + + /// 디자인 무드 선택 팝업 메뉴를 표시합니다. + private void ShowMoodMenu() + { + MoodMenuItems.Children.Clear(); + + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var borderBrush = ThemeResourceHelper.Border(this); + + // 2열 갤러리 그리드 + var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; + + foreach (var mood in TemplateService.AllMoods) + { + var isActive = _selectedMood == mood.Key; + var isCustom = 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 = ThemeResourceHelper.HexBrush(colors.Background), + BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(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 = ThemeResourceHelper.HexBrush(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 = ThemeResourceHelper.HexBrush(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(ThemeResourceHelper.HexColor(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 = ThemeResourceHelper.HexBrush(colors.CardBg), + BorderBrush = ThemeResourceHelper.HexBrush(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; + Llm.DefaultMood = capturedMood.Key; + _settings.Save(); + 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 = ThemeResourceHelper.SegoeMdl2, + 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 (FindName("BtnMoodMenu") is UIElement moodTarget) + MoodMenuPopup.PlacementTarget = moodTarget; + MoodMenuPopup.IsOpen = true; + } + + /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. + 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 + { + 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(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 = ThemeResourceHelper.Background(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var borderBrush = ThemeResourceHelper.Border(this); + + 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 = 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) + { + Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); + if (_selectedMood == moodKey) _selectedMood = "modern"; + _settings.Save(); + TemplateService.LoadCustomMoods(Llm.CustomMoods); + BuildBottomBar(); + } + }; + stack.Children.Add(deleteItem); + + menuBorder.Child = stack; + popup.Child = menuBorder; + popup.IsOpen = true; + } + + + private string? _promptCardPlaceholder; + + private void ShowPlaceholder() + { + if (string.IsNullOrEmpty(_promptCardPlaceholder)) return; + InputWatermark.Text = _promptCardPlaceholder; + InputWatermark.Visibility = Visibility.Visible; + InputBox.Text = ""; + InputBox.Focus(); + } + + private void UpdateWatermarkVisibility() + { + // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) + if (_activeSlashCmd != null) + { + InputWatermark.Visibility = Visibility.Collapsed; + return; + } + + if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text)) + InputWatermark.Visibility = Visibility.Visible; + else + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void ClearPromptCardPlaceholder() + { + _promptCardPlaceholder = null; + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void BtnSettings_Click(object sender, RoutedEventArgs e) + { + // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) + { + ToggleSettingsPanel(); + return; + } + + if (System.Windows.Application.Current is App app) + app.OpenSettingsFromChat(); + } + + /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글. + private void ToggleSettingsPanel() + { + if (SettingsPanel.IsOpen) + { + SettingsPanel.IsOpen = false; + } + else + { + var activeTab = "Chat"; + if (TabCowork?.IsChecked == true) activeTab = "Cowork"; + else if (TabCode?.IsChecked == true) activeTab = "Code"; + + SettingsPanel.LoadFromSettings(_settings, activeTab); + SettingsPanel.CloseRequested -= OnSettingsPanelClose; + SettingsPanel.CloseRequested += OnSettingsPanelClose; + SettingsPanel.IsOpen = true; + } + } + + private void OnSettingsPanelClose(object? sender, EventArgs e) + { + SettingsPanel.IsOpen = false; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.FileBrowser.cs b/src/AxCopilot/Views/ChatWindow.FileBrowser.cs new file mode 100644 index 0000000..507fee4 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.FileBrowser.cs @@ -0,0 +1,408 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 에이전트 스티키 진행률 바 ────────────────────────────────────────── + + private DateTime _progressStartTime; + private DispatcherTimer? _progressElapsedTimer; + + private void UpdateAgentProgressBar(AgentEvent evt) + { + switch (evt.Type) + { + case AgentEventType.Planning when evt.Steps is { Count: > 0 }: + ShowStickyProgress(evt.Steps.Count); + break; + + case AgentEventType.StepStart when evt.StepTotal > 0: + UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary); + break; + + case AgentEventType.Complete: + HideStickyProgress(); + break; + } + } + + private void ShowStickyProgress(int totalSteps) + { + _progressStartTime = DateTime.Now; + AgentProgressBar.Visibility = Visibility.Visible; + ProgressIcon.Text = "\uE768"; // play + ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})"; + ProgressPercent.Text = "0%"; + ProgressElapsed.Text = "0:00"; + ProgressFill.Width = 0; + + // 경과 시간 타이머 + _progressElapsedTimer?.Stop(); + _progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _progressElapsedTimer.Tick += (_, _) => + { + var elapsed = DateTime.Now - _progressStartTime; + ProgressElapsed.Text = elapsed.TotalHours >= 1 + ? elapsed.ToString(@"h\:mm\:ss") + : elapsed.ToString(@"m\:ss"); + }; + _progressElapsedTimer.Start(); + } + + private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription) + { + if (AgentProgressBar.Visibility != Visibility.Visible) return; + + var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0; + ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})"; + ProgressPercent.Text = $"{(int)(pct * 100)}%"; + + // 프로그레스 바 너비 애니메이션 + var parentBorder = ProgressFill.Parent as Border; + if (parentBorder != null) + { + var targetWidth = parentBorder.ActualWidth * pct; + var anim = new System.Windows.Media.Animation.DoubleAnimation( + ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300)) + { + EasingFunction = new System.Windows.Media.Animation.QuadraticEase(), + }; + ProgressFill.BeginAnimation(WidthProperty, anim); + } + } + + private void HideStickyProgress() + { + _progressElapsedTimer?.Stop(); + _progressElapsedTimer = null; + + if (AgentProgressBar.Visibility != Visibility.Visible) return; + + // 완료 표시 후 페이드아웃 + ProgressIcon.Text = "\uE930"; // check + ProgressStepLabel.Text = "작업 완료"; + ProgressPercent.Text = "100%"; + + // 프로그레스 바 100% + var parentBorder = ProgressFill.Parent as Border; + if (parentBorder != null) + { + var anim = new System.Windows.Media.Animation.DoubleAnimation( + ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200)); + ProgressFill.BeginAnimation(WidthProperty, anim); + } + + // 3초 후 숨기기 + var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; + hideTimer.Tick += (_, _) => + { + hideTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => + { + AgentProgressBar.Visibility = Visibility.Collapsed; + AgentProgressBar.Opacity = 1; + ProgressFill.BeginAnimation(WidthProperty, null); + ProgressFill.Width = 0; + }; + AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + hideTimer.Start(); + } + + // ─── 파일 탐색기 ────────────────────────────────────────────────────── + + private static readonly HashSet _ignoredDirs = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode", + "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", + ".cache", ".next", ".nuxt", "coverage", ".terraform", + }; + + private DispatcherTimer? _fileBrowserRefreshTimer; + + private void ToggleFileBrowser() + { + if (FileBrowserPanel.Visibility == Visibility.Visible) + { + FileBrowserPanel.Visibility = Visibility.Collapsed; + Llm.ShowFileBrowser = false; + } + else + { + FileBrowserPanel.Visibility = Visibility.Visible; + Llm.ShowFileBrowser = true; + BuildFileTree(); + } + _settings.Save(); + } + + private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree(); + + private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e) + { + var folder = GetCurrentWorkFolder(); + if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return; + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch (Exception) { /* 폴더 열기 실패 */ } + } + + private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e) + { + FileBrowserPanel.Visibility = Visibility.Collapsed; + } + + private void BuildFileTree() + { + FileTreeView.Items.Clear(); + var folder = GetCurrentWorkFolder(); + if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) + { + FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false }); + return; + } + + FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}"; + var count = 0; + PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count); + } + + private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count) + { + if (depth > 4 || count > 200) return; + + // 디렉터리 + try + { + foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name)) + { + if (count > 200) break; + if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue; + + count++; + var dirItem = new TreeViewItem + { + Header = CreateFileTreeHeader("\uED25", subDir.Name, null), + Tag = subDir.FullName, + IsExpanded = depth < 1, + }; + + // 지연 로딩: 더미 자식 → 펼칠 때 실제 로드 + if (depth < 3) + { + dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미 + var capturedDir = subDir; + var capturedDepth = depth; + dirItem.Expanded += (s, _) => + { + if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...") + { + ti.Items.Clear(); + int c = 0; + PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c); + } + }; + } + else + { + PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count); + } + + items.Add(dirItem); + } + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + + // 파일 + try + { + foreach (var file in dir.GetFiles().OrderBy(f => f.Name)) + { + if (count > 200) break; + count++; + + var ext = file.Extension.ToLowerInvariant(); + var icon = GetFileIcon(ext); + var size = FormatFileSize(file.Length); + + var fileItem = new TreeViewItem + { + Header = CreateFileTreeHeader(icon, file.Name, size), + Tag = file.FullName, + }; + + // 더블클릭 → 프리뷰 + var capturedPath = file.FullName; + fileItem.MouseDoubleClick += (s, e) => + { + e.Handled = true; + TryShowPreview(capturedPath); + }; + + // 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음) + fileItem.MouseRightButtonUp += (s, e) => + { + e.Handled = true; + if (s is TreeViewItem ti) ti.IsSelected = true; + ShowFileTreeContextMenu(capturedPath); + }; + + items.Add(fileItem); + } + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + } + + private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 5, 0), + }); + sp.Children.Add(new TextBlock + { + Text = name, + FontSize = 11.5, + VerticalAlignment = VerticalAlignment.Center, + }); + if (sizeText != null) + { + sp.Children.Add(new TextBlock + { + Text = $" {sizeText}", + FontSize = 10, + Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)), + VerticalAlignment = VerticalAlignment.Center, + }); + } + return sp; + } + + private static string GetFileIcon(string ext) => ext switch + { + ".html" or ".htm" => "\uEB41", + ".xlsx" or ".xls" => "\uE9F9", + ".docx" or ".doc" => "\uE8A5", + ".pdf" => "\uEA90", + ".csv" => "\uE80A", + ".md" => "\uE70B", + ".json" or ".xml" => "\uE943", + ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F", + ".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943", + ".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756", + ".txt" or ".log" => "\uE8A5", + _ => "\uE7C3", + }; + + private static string FormatFileSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB", + }; + + private void ShowFileTreeContextMenu(string filePath) + { + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var hoverBg = ThemeResourceHelper.Hint(this); + var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C)); + + var (popup, panel) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200); + + void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null) + => panel.Children.Add(PopupMenuHelper.MenuItem(label, labelColor ?? primaryText, hoverBg, + () => { popup.IsOpen = false; action(); }, + icon: icon, iconColor: iconColor ?? secondaryText, fontSize: 12.5)); + + void AddSep() => panel.Children.Add(PopupMenuHelper.Separator()); + + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + if (_previewableExtensions.Contains(ext)) + AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath)); + + AddItem("\uE8A7", "외부 프로그램으로 열기", () => + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ } + }); + AddItem("\uED25", "폴더에서 보기", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch (Exception) { /* 탐색기 열기 실패 */ } + }); + AddItem("\uE8C8", "경로 복사", () => + { + try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + AddSep(); + + // 이름 변경 + AddItem("\uE8AC", "이름 변경", () => + { + var dir = System.IO.Path.GetDirectoryName(filePath) ?? ""; + var oldName = System.IO.Path.GetFileName(filePath); + var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this }; + if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText)) + { + var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim()); + try + { + System.IO.File.Move(filePath, newPath); + BuildFileTree(); + ShowToast($"이름 변경: {dlg.ResponseText.Trim()}"); + } + catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); } + } + }); + + // 삭제 + AddItem("\uE74D", "삭제", () => + { + var result = MessageBox.Show( + $"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}", + "파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + { + try + { + System.IO.File.Delete(filePath); + BuildFileTree(); + ShowToast("파일 삭제됨"); + } + catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); } + } + }, dangerBrush, dangerBrush); + + // Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음 + Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, + System.Windows.Threading.DispatcherPriority.Input); + } + + /// 에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다. + private void RefreshFileTreeIfVisible() + { + if (FileBrowserPanel.Visibility != Visibility.Visible) return; + + // 디바운스: 500ms 내 중복 호출 방지 + _fileBrowserRefreshTimer?.Stop(); + _fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _fileBrowserRefreshTimer.Tick += (_, _) => + { + _fileBrowserRefreshTimer.Stop(); + BuildFileTree(); + }; + _fileBrowserRefreshTimer.Start(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.Presets.cs b/src/AxCopilot/Views/ChatWindow.Presets.cs index bf0e68f..41adeab 100644 --- a/src/AxCopilot/Views/ChatWindow.Presets.cs +++ b/src/AxCopilot/Views/ChatWindow.Presets.cs @@ -312,969 +312,4 @@ public partial class ChatWindow TopicButtonPanel.Children.Add(addBorder); } } - - // ─── 커스텀 프리셋 관리 ───────────────────────────────────────────── - - /// 커스텀 프리셋 추가 다이얼로그를 표시합니다. - private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null) - { - bool isEdit = existing != null; - var dlg = new CustomPresetDialog( - existingName: existing?.Label ?? "", - existingDesc: existing?.Description ?? "", - existingPrompt: existing?.SystemPrompt ?? "", - existingColor: existing?.Color ?? "#6366F1", - existingSymbol: existing?.Symbol ?? "\uE713", - existingTab: existing?.Tab ?? _activeTab) - { - Owner = this, - }; - - if (dlg.ShowDialog() == true) - { - if (isEdit) - { - existing!.Label = dlg.PresetName; - existing.Description = dlg.PresetDescription; - existing.SystemPrompt = dlg.PresetSystemPrompt; - existing.Color = dlg.PresetColor; - existing.Symbol = dlg.PresetSymbol; - existing.Tab = dlg.PresetTab; - } - else - { - Llm.CustomPresets.Add(new Models.CustomPresetEntry - { - Label = dlg.PresetName, - Description = dlg.PresetDescription, - SystemPrompt = dlg.PresetSystemPrompt, - Color = dlg.PresetColor, - Symbol = dlg.PresetSymbol, - Tab = dlg.PresetTab, - }); - } - _settings.Save(); - BuildTopicButtons(); - } - } - - /// 커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다. - private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset) - { - if (anchor == null || preset.CustomId == null) return; - - var popup = new System.Windows.Controls.Primitives.Popup - { - PlacementTarget = anchor, - Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, - StaysOpen = false, - AllowsTransparency = true, - }; - - var menuBg = ThemeResourceHelper.Background(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var borderBrush = ThemeResourceHelper.Border(this); - - 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 = Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId); - if (entry != null) ShowCustomPresetDialog(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( - $"'{preset.Label}' 프리셋을 삭제하시겠습니까?", - "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId); - _settings.Save(); - BuildTopicButtons(); - } - }; - stack.Children.Add(deleteItem); - - menuBorder.Child = stack; - popup.Child = menuBorder; - popup.IsOpen = true; - } - - /// 컨텍스트 메뉴 항목을 생성합니다. - private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg) - { - var item = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(10, 6, 14, 6), - Cursor = Cursors.Hand, - }; - - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, Foreground = fg, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 13, Foreground = fg, - VerticalAlignment = VerticalAlignment.Center, - }); - item.Child = sp; - - var hoverBg = ThemeResourceHelper.HoverBg(this); - item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; - item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - - return item; - } - - /// 대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용. - private void SelectTopic(Services.TopicPreset preset) - { - bool hasMessages; - lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0; - - // 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존) - bool hasInput = !string.IsNullOrEmpty(InputBox.Text); - bool keepConversation = hasMessages || hasInput; - - if (!keepConversation) - { - // 메시지도 입력 텍스트도 없으면 새 대화 시작 - StartNewConversation(); - } - - // 프리셋 적용 (기존 대화에도 프리셋 변경 가능) - lock (_convLock) - { - if (_currentConversation != null) - { - _currentConversation.SystemCommand = preset.SystemPrompt; - _currentConversation.Category = preset.Category; - } - } - - if (!keepConversation) - EmptyState.Visibility = Visibility.Collapsed; - - InputBox.Focus(); - - if (!string.IsNullOrEmpty(preset.Placeholder)) - { - _promptCardPlaceholder = preset.Placeholder; - if (!keepConversation) ShowPlaceholder(); - } - - if (keepConversation) - ShowToast($"프리셋 변경: {preset.Label}"); - - // Cowork 탭: 하단 바 갱신 - if (_activeTab == "Cowork") - BuildBottomBar(); - } - - - - /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). - private string _selectedMood = null!; // Loaded 이벤트에서 초기화 - private string _selectedLanguage = "auto"; // Code 탭 개발 언어 - private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 - - /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼). - private void BuildBottomBar() - { - MoodIconPanel.Children.Clear(); - - var secondaryText = ThemeResourceHelper.Secondary(this); - - // ── 포맷 버튼 ── - var currentFormat = Llm.DefaultOutputFormat ?? "auto"; - var formatLabel = GetFormatLabel(currentFormat); - var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6"); - formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); }; - // Name 등록 (Popup PlacementTarget용) - try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(formatBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // ── 디자인 버튼 (소극 스타일) ── - var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood); - var moodLabel = currentMood?.Label ?? "모던"; - var moodIcon = currentMood?.Icon ?? "🔷"; - var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택"); - moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); }; - try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(moodBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // ── 파일 탐색기 토글 버튼 ── - var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); - fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; - MoodIconPanel.Children.Add(fileBrowserBtn); - - // ── 실행 이력 상세도 버튼 ── - AppendLogLevelButton(); - - // 구분선 표시 - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; - } - - /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글. - private void BuildCodeBottomBar() - { - MoodIconPanel.Children.Clear(); - - var secondaryText = ThemeResourceHelper.Secondary(this); - - // 개발 언어 선택 버튼 - var langLabel = _selectedLanguage switch - { - "python" => "🐍 Python", - "java" => "☕ Java", - "csharp" => "🔷 C#", - "cpp" => "⚙ C++", - "javascript" => "🌐 JavaScript", - _ => "🔧 자동 감지", - }; - var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택"); - langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); }; - try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(langBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // 파일 탐색기 토글 - var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); - fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; - MoodIconPanel.Children.Add(fileBrowserBtn); - - // ── 실행 이력 상세도 버튼 ── - AppendLogLevelButton(); - - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; - } - - /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. - private void AppendLogLevelButton() - { - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - var currentLevel = 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 (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(logBtn); - } - - /// 실행 이력 상세도 팝업 메뉴를 표시합니다. - private void ShowLogLevelMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = ThemeResourceHelper.Primary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - var levels = new (string Key, string Label, string Desc)[] - { - ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), - ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), - ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), - }; - - var current = 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 = ThemeResourceHelper.HoverBg(this); - item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; - item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; - item.MouseLeftButtonUp += (_, _) => - { - 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 (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - FormatMenuPopup.IsOpen = true; - } - - private void ShowLanguageMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = ThemeResourceHelper.Primary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - - 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 = ThemeResourceHelper.Secondary(this); - 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 = ThemeResourceHelper.SegoeMdl2, - 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, - }); - - return new Border - { - Child = sp, - Background = Brushes.Transparent, - Padding = new Thickness(6, 4, 6, 4), - Cursor = Cursors.Hand, - ToolTip = tooltip, - }; - } - - - 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 = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var currentFormat = 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 = ThemeResourceHelper.SegoeMdl2, - 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; - Llm.DefaultOutputFormat = capturedKey; - _settings.Save(); - BuildBottomBar(); - }; - - FormatMenuItems.Children.Add(itemBorder); - } - - // PlacementTarget을 동적 등록된 버튼으로 설정 - if (FindName("BtnFormatMenu") is UIElement formatTarget) - FormatMenuPopup.PlacementTarget = formatTarget; - FormatMenuPopup.IsOpen = true; - } - - /// 디자인 무드 선택 팝업 메뉴를 표시합니다. - private void ShowMoodMenu() - { - MoodMenuItems.Children.Clear(); - - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var borderBrush = ThemeResourceHelper.Border(this); - - // 2열 갤러리 그리드 - var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; - - foreach (var mood in TemplateService.AllMoods) - { - var isActive = _selectedMood == mood.Key; - var isCustom = 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 = ThemeResourceHelper.HexBrush(colors.Background), - BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(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 = ThemeResourceHelper.HexBrush(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 = ThemeResourceHelper.HexBrush(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(ThemeResourceHelper.HexColor(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 = ThemeResourceHelper.HexBrush(colors.CardBg), - BorderBrush = ThemeResourceHelper.HexBrush(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; - Llm.DefaultMood = capturedMood.Key; - _settings.Save(); - 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 = ThemeResourceHelper.SegoeMdl2, - 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 (FindName("BtnMoodMenu") is UIElement moodTarget) - MoodMenuPopup.PlacementTarget = moodTarget; - MoodMenuPopup.IsOpen = true; - } - - /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. - 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 - { - 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(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 = ThemeResourceHelper.Background(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var borderBrush = ThemeResourceHelper.Border(this); - - 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 = 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) - { - Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); - if (_selectedMood == moodKey) _selectedMood = "modern"; - _settings.Save(); - TemplateService.LoadCustomMoods(Llm.CustomMoods); - BuildBottomBar(); - } - }; - stack.Children.Add(deleteItem); - - menuBorder.Child = stack; - popup.Child = menuBorder; - popup.IsOpen = true; - } - - - private string? _promptCardPlaceholder; - - private void ShowPlaceholder() - { - if (string.IsNullOrEmpty(_promptCardPlaceholder)) return; - InputWatermark.Text = _promptCardPlaceholder; - InputWatermark.Visibility = Visibility.Visible; - InputBox.Text = ""; - InputBox.Focus(); - } - - private void UpdateWatermarkVisibility() - { - // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) - if (_activeSlashCmd != null) - { - InputWatermark.Visibility = Visibility.Collapsed; - return; - } - - if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text)) - InputWatermark.Visibility = Visibility.Visible; - else - InputWatermark.Visibility = Visibility.Collapsed; - } - - private void ClearPromptCardPlaceholder() - { - _promptCardPlaceholder = null; - InputWatermark.Visibility = Visibility.Collapsed; - } - - private void BtnSettings_Click(object sender, RoutedEventArgs e) - { - // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow - if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) - { - ToggleSettingsPanel(); - return; - } - - if (System.Windows.Application.Current is App app) - app.OpenSettingsFromChat(); - } - - /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글. - private void ToggleSettingsPanel() - { - if (SettingsPanel.IsOpen) - { - SettingsPanel.IsOpen = false; - } - else - { - var activeTab = "Chat"; - if (TabCowork?.IsChecked == true) activeTab = "Cowork"; - else if (TabCode?.IsChecked == true) activeTab = "Code"; - - SettingsPanel.LoadFromSettings(_settings, activeTab); - SettingsPanel.CloseRequested -= OnSettingsPanelClose; - SettingsPanel.CloseRequested += OnSettingsPanelClose; - SettingsPanel.IsOpen = true; - } - } - - private void OnSettingsPanelClose(object? sender, EventArgs e) - { - SettingsPanel.IsOpen = false; - } } diff --git a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs index 1ada39d..7920b87 100644 --- a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs +++ b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs @@ -706,400 +706,4 @@ public partial class ChatWindow win.Content = content; win.Show(); } - - // ─── 에이전트 스티키 진행률 바 ────────────────────────────────────────── - - private DateTime _progressStartTime; - private DispatcherTimer? _progressElapsedTimer; - - private void UpdateAgentProgressBar(AgentEvent evt) - { - switch (evt.Type) - { - case AgentEventType.Planning when evt.Steps is { Count: > 0 }: - ShowStickyProgress(evt.Steps.Count); - break; - - case AgentEventType.StepStart when evt.StepTotal > 0: - UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary); - break; - - case AgentEventType.Complete: - HideStickyProgress(); - break; - } - } - - private void ShowStickyProgress(int totalSteps) - { - _progressStartTime = DateTime.Now; - AgentProgressBar.Visibility = Visibility.Visible; - ProgressIcon.Text = "\uE768"; // play - ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})"; - ProgressPercent.Text = "0%"; - ProgressElapsed.Text = "0:00"; - ProgressFill.Width = 0; - - // 경과 시간 타이머 - _progressElapsedTimer?.Stop(); - _progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; - _progressElapsedTimer.Tick += (_, _) => - { - var elapsed = DateTime.Now - _progressStartTime; - ProgressElapsed.Text = elapsed.TotalHours >= 1 - ? elapsed.ToString(@"h\:mm\:ss") - : elapsed.ToString(@"m\:ss"); - }; - _progressElapsedTimer.Start(); - } - - private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription) - { - if (AgentProgressBar.Visibility != Visibility.Visible) return; - - var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0; - ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})"; - ProgressPercent.Text = $"{(int)(pct * 100)}%"; - - // 프로그레스 바 너비 애니메이션 - var parentBorder = ProgressFill.Parent as Border; - if (parentBorder != null) - { - var targetWidth = parentBorder.ActualWidth * pct; - var anim = new System.Windows.Media.Animation.DoubleAnimation( - ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300)) - { - EasingFunction = new System.Windows.Media.Animation.QuadraticEase(), - }; - ProgressFill.BeginAnimation(WidthProperty, anim); - } - } - - private void HideStickyProgress() - { - _progressElapsedTimer?.Stop(); - _progressElapsedTimer = null; - - if (AgentProgressBar.Visibility != Visibility.Visible) return; - - // 완료 표시 후 페이드아웃 - ProgressIcon.Text = "\uE930"; // check - ProgressStepLabel.Text = "작업 완료"; - ProgressPercent.Text = "100%"; - - // 프로그레스 바 100% - var parentBorder = ProgressFill.Parent as Border; - if (parentBorder != null) - { - var anim = new System.Windows.Media.Animation.DoubleAnimation( - ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200)); - ProgressFill.BeginAnimation(WidthProperty, anim); - } - - // 3초 후 숨기기 - var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; - hideTimer.Tick += (_, _) => - { - hideTimer.Stop(); - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => - { - AgentProgressBar.Visibility = Visibility.Collapsed; - AgentProgressBar.Opacity = 1; - ProgressFill.BeginAnimation(WidthProperty, null); - ProgressFill.Width = 0; - }; - AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - hideTimer.Start(); - } - - // ─── 파일 탐색기 ────────────────────────────────────────────────────── - - private static readonly HashSet _ignoredDirs = new(StringComparer.OrdinalIgnoreCase) - { - "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode", - "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", - ".cache", ".next", ".nuxt", "coverage", ".terraform", - }; - - private DispatcherTimer? _fileBrowserRefreshTimer; - - private void ToggleFileBrowser() - { - if (FileBrowserPanel.Visibility == Visibility.Visible) - { - FileBrowserPanel.Visibility = Visibility.Collapsed; - Llm.ShowFileBrowser = false; - } - else - { - FileBrowserPanel.Visibility = Visibility.Visible; - Llm.ShowFileBrowser = true; - BuildFileTree(); - } - _settings.Save(); - } - - private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree(); - - private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e) - { - var folder = GetCurrentWorkFolder(); - if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return; - try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch (Exception) { /* 폴더 열기 실패 */ } - } - - private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e) - { - FileBrowserPanel.Visibility = Visibility.Collapsed; - } - - private void BuildFileTree() - { - FileTreeView.Items.Clear(); - var folder = GetCurrentWorkFolder(); - if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) - { - FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false }); - return; - } - - FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}"; - var count = 0; - PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count); - } - - private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count) - { - if (depth > 4 || count > 200) return; - - // 디렉터리 - try - { - foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name)) - { - if (count > 200) break; - if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue; - - count++; - var dirItem = new TreeViewItem - { - Header = CreateFileTreeHeader("\uED25", subDir.Name, null), - Tag = subDir.FullName, - IsExpanded = depth < 1, - }; - - // 지연 로딩: 더미 자식 → 펼칠 때 실제 로드 - if (depth < 3) - { - dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미 - var capturedDir = subDir; - var capturedDepth = depth; - dirItem.Expanded += (s, _) => - { - if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...") - { - ti.Items.Clear(); - int c = 0; - PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c); - } - }; - } - else - { - PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count); - } - - items.Add(dirItem); - } - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - - // 파일 - try - { - foreach (var file in dir.GetFiles().OrderBy(f => f.Name)) - { - if (count > 200) break; - count++; - - var ext = file.Extension.ToLowerInvariant(); - var icon = GetFileIcon(ext); - var size = FormatFileSize(file.Length); - - var fileItem = new TreeViewItem - { - Header = CreateFileTreeHeader(icon, file.Name, size), - Tag = file.FullName, - }; - - // 더블클릭 → 프리뷰 - var capturedPath = file.FullName; - fileItem.MouseDoubleClick += (s, e) => - { - e.Handled = true; - TryShowPreview(capturedPath); - }; - - // 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음) - fileItem.MouseRightButtonUp += (s, e) => - { - e.Handled = true; - if (s is TreeViewItem ti) ti.IsSelected = true; - ShowFileTreeContextMenu(capturedPath); - }; - - items.Add(fileItem); - } - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - } - - private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 11, - Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 5, 0), - }); - sp.Children.Add(new TextBlock - { - Text = name, - FontSize = 11.5, - VerticalAlignment = VerticalAlignment.Center, - }); - if (sizeText != null) - { - sp.Children.Add(new TextBlock - { - Text = $" {sizeText}", - FontSize = 10, - Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)), - VerticalAlignment = VerticalAlignment.Center, - }); - } - return sp; - } - - private static string GetFileIcon(string ext) => ext switch - { - ".html" or ".htm" => "\uEB41", - ".xlsx" or ".xls" => "\uE9F9", - ".docx" or ".doc" => "\uE8A5", - ".pdf" => "\uEA90", - ".csv" => "\uE80A", - ".md" => "\uE70B", - ".json" or ".xml" => "\uE943", - ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F", - ".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943", - ".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756", - ".txt" or ".log" => "\uE8A5", - _ => "\uE7C3", - }; - - private static string FormatFileSize(long bytes) => bytes switch - { - < 1024 => $"{bytes} B", - < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", - _ => $"{bytes / (1024.0 * 1024.0):F1} MB", - }; - - private void ShowFileTreeContextMenu(string filePath) - { - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var hoverBg = ThemeResourceHelper.Hint(this); - var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C)); - - var (popup, panel) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200); - - void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null) - => panel.Children.Add(PopupMenuHelper.MenuItem(label, labelColor ?? primaryText, hoverBg, - () => { popup.IsOpen = false; action(); }, - icon: icon, iconColor: iconColor ?? secondaryText, fontSize: 12.5)); - - void AddSep() => panel.Children.Add(PopupMenuHelper.Separator()); - - var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); - if (_previewableExtensions.Contains(ext)) - AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath)); - - AddItem("\uE8A7", "외부 프로그램으로 열기", () => - { - try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ } - }); - AddItem("\uED25", "폴더에서 보기", () => - { - try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch (Exception) { /* 탐색기 열기 실패 */ } - }); - AddItem("\uE8C8", "경로 복사", () => - { - try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - AddSep(); - - // 이름 변경 - AddItem("\uE8AC", "이름 변경", () => - { - var dir = System.IO.Path.GetDirectoryName(filePath) ?? ""; - var oldName = System.IO.Path.GetFileName(filePath); - var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this }; - if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText)) - { - var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim()); - try - { - System.IO.File.Move(filePath, newPath); - BuildFileTree(); - ShowToast($"이름 변경: {dlg.ResponseText.Trim()}"); - } - catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); } - } - }); - - // 삭제 - AddItem("\uE74D", "삭제", () => - { - var result = MessageBox.Show( - $"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}", - "파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); - if (result == MessageBoxResult.Yes) - { - try - { - System.IO.File.Delete(filePath); - BuildFileTree(); - ShowToast("파일 삭제됨"); - } - catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); } - } - }, dangerBrush, dangerBrush); - - // Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음 - Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, - System.Windows.Threading.DispatcherPriority.Input); - } - - /// 에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다. - private void RefreshFileTreeIfVisible() - { - if (FileBrowserPanel.Visibility != Visibility.Visible) return; - - // 디바운스: 500ms 내 중복 호출 방지 - _fileBrowserRefreshTimer?.Stop(); - _fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; - _fileBrowserRefreshTimer.Tick += (_, _) => - { - _fileBrowserRefreshTimer.Stop(); - BuildFileTree(); - }; - _fileBrowserRefreshTimer.Start(); - } } diff --git a/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs b/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs index b2e1a1b..fc0dd9d 100644 --- a/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs +++ b/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs @@ -605,598 +605,4 @@ public partial class SettingsWindow app?.MemoryService?.Clear(); CustomMessageBox.Show("에이전트 메모리가 초기화되었습니다.", "완료", MessageBoxButton.OK, MessageBoxImage.Information); } - - // ─── 에이전트 훅 관리 ───────────────────────────────────────────────── - - private void AddHookBtn_Click(object sender, MouseButtonEventArgs e) - { - ShowHookEditDialog(null, -1); - } - - /// 플레이스홀더(워터마크) TextBlock 생성 헬퍼. - private static TextBlock CreatePlaceholder(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 ShowHookEditDialog(Models.AgentHookEntry? existing, int index) - { - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60)); - var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40)); - 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, - }; - - 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 ? "\u2699 훅 추가" : "\u2699 훅 편집", - FontSize = 15, FontWeight = FontWeights.SemiBold, - Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 14), - }); - - // ESC 키로 닫기 - dlg.KeyDown += (_, e) => { if (e.Key == System.Windows.Input.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 = CreatePlaceholder("예: 코드 리뷰 후 알림", 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 = CreatePlaceholder("예: 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 { Width = new GridLength(1, GridUnitType.Star) }); - 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 = CreatePlaceholder("예: 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); - Grid.SetColumn(pathInnerGrid, 0); - 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, - }; - browseBtn.Child = new TextBlock { Text = "...", FontSize = 13, Foreground = accentBrush }; - browseBtn.MouseLeftButtonUp += (_, _) => - { - var ofd = new Microsoft.Win32.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 = CreatePlaceholder("예: --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, - }; - cancelBorder.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, - }; - saveBorder.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 Models.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 = _vm.Service.Settings.Llm.AgentHooks; - if (isNew) - hooks.Add(entry); - else if (index >= 0 && index < hooks.Count) - hooks[index] = entry; - - BuildHookCards(); - dlg.Close(); - }; - btnRow.Children.Add(saveBorder); - stack.Children.Add(btnRow); - - border.Child = stack; - dlg.Content = border; - dlg.ShowDialog(); - } - - private void BuildHookCards() - { - if (HookListPanel == null) return; - HookListPanel.Children.Clear(); - - var hooks = _vm.Service.Settings.Llm.AgentHooks; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; - - 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.LightGray, - CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 4), - }; - - 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.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, - }; - var capturedHook = hook; - toggle.Checked += (_, _) => capturedHook.Enabled = true; - toggle.Unchecked += (_, _) => capturedHook.Enabled = false; - Grid.SetColumn(toggle, 0); - grid.Children.Add(toggle); - - // 정보 - var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - var timingBadge = hook.Timing == "pre" ? "PRE" : "POST"; - var timingColor = hook.Timing == "pre" ? "#FF9800" : "#4CAF50"; - var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; - headerPanel.Children.Add(new TextBlock - { - Text = hook.Name, FontSize = 13, FontWeight = FontWeights.SemiBold, - Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, - }); - headerPanel.Children.Add(new Border - { - Background = ThemeResourceHelper.HexBrush(timingColor), - CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock { Text = timingBadge, FontSize = 9, Foreground = Brushes.White, FontWeight = FontWeights.Bold }, - }); - if (hook.ToolName != "*") - { - headerPanel.Children.Add(new Border - { - Background = new SolidColorBrush(Color.FromArgb(40, 100, 100, 255)), - CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock { Text = hook.ToolName, FontSize = 9, Foreground = accentBrush }, - }); - } - info.Children.Add(headerPanel); - info.Children.Add(new TextBlock - { - Text = System.IO.Path.GetFileName(hook.ScriptPath), - FontSize = 11, Foreground = secondaryText, - TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 200, - }); - Grid.SetColumn(info, 1); - grid.Children.Add(info); - - // 편집 버튼 - var editBtn = new Border - { - Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 4, 0), Padding = new Thickness(6), - }; - editBtn.Child = new TextBlock - { - Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = secondaryText, - }; - editBtn.MouseLeftButtonUp += (_, _) => ShowHookEditDialog(hooks[idx], idx); - Grid.SetColumn(editBtn, 2); - grid.Children.Add(editBtn); - - // 삭제 버튼 - var delBtn = new Border - { - Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 0, 0), Padding = new Thickness(6), - }; - delBtn.Child = new TextBlock - { - Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), - }; - delBtn.MouseLeftButtonUp += (_, _) => - { - hooks.RemoveAt(idx); - BuildHookCards(); - }; - Grid.SetColumn(delBtn, 3); - grid.Children.Add(delBtn); - - card.Child = grid; - HookListPanel.Children.Add(card); - } - } - - // ─── MCP 서버 관리 ───────────────────────────────────────────────── - private void BtnAddMcpServer_Click(object sender, RoutedEventArgs e) - { - var dlg = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server"); - dlg.Owner = this; - if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.ResponseText)) return; - - var name = dlg.ResponseText.Trim(); - var cmdDlg = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @anthropic/mcp-server"); - cmdDlg.Owner = this; - if (cmdDlg.ShowDialog() != true || string.IsNullOrWhiteSpace(cmdDlg.ResponseText)) return; - - var entry = new Models.McpServerEntry { Name = name, Command = cmdDlg.ResponseText.Trim(), Enabled = true }; - _vm.Service.Settings.Llm.McpServers.Add(entry); - BuildMcpServerCards(); - } - - private void BuildMcpServerCards() - { - if (McpServerListPanel == null) return; - McpServerListPanel.Children.Clear(); - - var servers = _vm.Service.Settings.Llm.McpServers; - var primaryText = TryFindResource("PrimaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Blue; - - for (int i = 0; i < servers.Count; i++) - { - var srv = servers[i]; - var idx = i; - - var card = new Border - { - Background = TryFindResource("ItemBackground") as System.Windows.Media.Brush, - CornerRadius = new CornerRadius(10), - Padding = new Thickness(14, 10, 14, 10), - Margin = new Thickness(0, 4, 0, 0), - BorderBrush = TryFindResource("BorderColor") as System.Windows.Media.Brush, - BorderThickness = new Thickness(1), - }; - - 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 { VerticalAlignment = VerticalAlignment.Center }; - info.Children.Add(new TextBlock - { - Text = srv.Name, FontSize = 13.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText, - }); - var detailSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; - detailSp.Children.Add(new Border - { - Background = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray, - CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 1, 6, 1), Margin = new Thickness(0, 0, 8, 0), Opacity = 0.8, - Child = new TextBlock { Text = srv.Enabled ? "활성" : "비활성", FontSize = 10, Foreground = System.Windows.Media.Brushes.White, FontWeight = FontWeights.SemiBold }, - }); - detailSp.Children.Add(new TextBlock - { - Text = $"{srv.Command} {string.Join(" ", srv.Args)}", FontSize = 11, - Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, - MaxWidth = 300, TextTrimming = TextTrimming.CharacterEllipsis, - }); - info.Children.Add(detailSp); - Grid.SetColumn(info, 0); - grid.Children.Add(info); - - var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; - - // 활성/비활성 토글 - var toggleBtn = new Button - { - Content = srv.Enabled ? "\uE73E" : "\uE711", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - FontSize = 12, ToolTip = srv.Enabled ? "비활성화" : "활성화", - Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0), - Foreground = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray, - Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand, - }; - toggleBtn.Click += (_, _) => { servers[idx].Enabled = !servers[idx].Enabled; BuildMcpServerCards(); }; - btnPanel.Children.Add(toggleBtn); - - // 삭제 - var delBtn = new Button - { - Content = "\uE74D", FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - FontSize = 12, ToolTip = "삭제", - Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0), - Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xDD, 0x44, 0x44)), - Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand, - }; - delBtn.Click += (_, _) => { servers.RemoveAt(idx); BuildMcpServerCards(); }; - btnPanel.Children.Add(delBtn); - - Grid.SetColumn(btnPanel, 1); - grid.Children.Add(btnPanel); - card.Child = grid; - McpServerListPanel.Children.Add(card); - } - } - - // ─── 감사 로그 폴더 열기 ──────────────────────────────────────────── - private void BtnOpenAuditLog_Click(object sender, RoutedEventArgs e) - { - try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { } - } - - // ─── 폴백/MCP 텍스트 박스 로드/저장 ─────────────────────────────────── - private void BuildFallbackModelsPanel() - { - if (FallbackModelsPanel == null) return; - FallbackModelsPanel.Children.Clear(); - - var llm = _vm.Service.Settings.Llm; - var fallbacks = llm.FallbackModels; - var toggleStyle = TryFindResource("ToggleSwitch") as Style; - - // 서비스별로 모델 수집 (순서 고정: Ollama → vLLM → Gemini → Claude) - var sections = new (string Service, string Label, string Color, List Models)[] - { - ("ollama", "Ollama", "#107C10", new()), - ("vllm", "vLLM", "#0078D4", new()), - ("gemini", "Gemini", "#4285F4", new()), - ("claude", "Claude", "#8B5CF6", new()), - }; - - // RegisteredModels → ViewModel과 AppSettings 양쪽에서 수집 (저장 전에도 반영) - // 1) ViewModel의 RegisteredModels (UI에서 방금 추가한 것 포함) - foreach (var row in _vm.RegisteredModels) - { - var svc = (row.Service ?? "").ToLowerInvariant(); - var modelName = !string.IsNullOrEmpty(row.Alias) ? row.Alias : row.EncryptedModelName; - var section = sections.FirstOrDefault(s => s.Service == svc); - if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName)) - section.Models.Add(modelName); - } - // 2) AppSettings의 RegisteredModels (기존 저장된 것 — ViewModel에 없는 경우 보완) - foreach (var m in llm.RegisteredModels) - { - var svc = (m.Service ?? "").ToLowerInvariant(); - var modelName = !string.IsNullOrEmpty(m.Alias) ? m.Alias : m.EncryptedModelName; - var section = sections.FirstOrDefault(s => s.Service == svc); - if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName)) - section.Models.Add(modelName); - } - - // 현재 활성 모델 추가 (중복 제거) - if (!string.IsNullOrEmpty(llm.OllamaModel) && !sections[0].Models.Contains(llm.OllamaModel)) - sections[0].Models.Add(llm.OllamaModel); - if (!string.IsNullOrEmpty(llm.VllmModel) && !sections[1].Models.Contains(llm.VllmModel)) - sections[1].Models.Add(llm.VllmModel); - - // Gemini/Claude 고정 모델 목록 - foreach (var gm in new[] { "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash" }) - if (!sections[2].Models.Contains(gm)) sections[2].Models.Add(gm); - foreach (var cm in new[] { "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "claude-sonnet-4-5" }) - if (!sections[3].Models.Contains(cm)) sections[3].Models.Add(cm); - - // 렌더링 — 모델이 없는 섹션도 헤더는 표시 - foreach (var (service, svcLabel, svcColor, models) in sections) - { - FallbackModelsPanel.Children.Add(new TextBlock - { - Text = svcLabel, - FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex(svcColor), - Margin = new Thickness(0, 8, 0, 4), - }); - - if (models.Count == 0) - { - FallbackModelsPanel.Children.Add(new TextBlock - { - Text = "등록된 모델 없음", - FontSize = 11, Foreground = Brushes.Gray, FontStyle = FontStyles.Italic, - Margin = new Thickness(8, 2, 0, 4), - }); - continue; - } - - foreach (var modelName in models) - { - var fullKey = $"{service}:{modelName}"; - - var row = new Grid { Margin = new Thickness(8, 2, 0, 2) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var label = new TextBlock - { - Text = modelName, FontSize = 12, FontFamily = ThemeResourceHelper.ConsolasCourierNew, - VerticalAlignment = VerticalAlignment.Center, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, - }; - Grid.SetColumn(label, 0); - row.Children.Add(label); - - var captured = fullKey; - var cb = new CheckBox - { - IsChecked = fallbacks.Contains(fullKey, StringComparer.OrdinalIgnoreCase), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - }; - if (toggleStyle != null) cb.Style = toggleStyle; - cb.Checked += (_, _) => - { - if (!fallbacks.Contains(captured)) fallbacks.Add(captured); - FallbackModelsBox.Text = string.Join("\n", fallbacks); - }; - cb.Unchecked += (_, _) => - { - fallbacks.RemoveAll(x => x.Equals(captured, StringComparison.OrdinalIgnoreCase)); - FallbackModelsBox.Text = string.Join("\n", fallbacks); - }; - Grid.SetColumn(cb, 1); - row.Children.Add(cb); - - FallbackModelsPanel.Children.Add(row); - } - } - } - - private void LoadAdvancedSettings() - { - var llm = _vm.Service.Settings.Llm; - if (FallbackModelsBox != null) - FallbackModelsBox.Text = string.Join("\n", llm.FallbackModels); - BuildFallbackModelsPanel(); - if (McpServersBox != null) - { - try - { - var json = System.Text.Json.JsonSerializer.Serialize(llm.McpServers, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - McpServersBox.Text = json; - } - catch (Exception) { McpServersBox.Text = "[]"; } - } - BuildMcpServerCards(); - BuildHookCards(); - } - - private void SaveAdvancedSettings() - { - var llm = _vm.Service.Settings.Llm; - if (FallbackModelsBox != null) - { - llm.FallbackModels = FallbackModelsBox.Text - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => s.Length > 0) - .ToList(); - } - if (McpServersBox != null && !string.IsNullOrWhiteSpace(McpServersBox.Text)) - { - try - { - llm.McpServers = System.Text.Json.JsonSerializer.Deserialize>( - McpServersBox.Text) ?? new(); - } - catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ } - } - - // 도구 비활성 목록 저장 - if (_toolCardsLoaded) - llm.DisabledTools = _disabledTools.ToList(); - } } diff --git a/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs b/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs new file mode 100644 index 0000000..5cec6c1 --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs @@ -0,0 +1,605 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class SettingsWindow +{ + // ─── 에이전트 훅 관리 ───────────────────────────────────────────────── + + private void AddHookBtn_Click(object sender, MouseButtonEventArgs e) + { + ShowHookEditDialog(null, -1); + } + + /// 플레이스홀더(워터마크) TextBlock 생성 헬퍼. + private static TextBlock CreatePlaceholder(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 ShowHookEditDialog(Models.AgentHookEntry? existing, int index) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60)); + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40)); + 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, + }; + + 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 ? "\u2699 훅 추가" : "\u2699 훅 편집", + FontSize = 15, FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 14), + }); + + // ESC 키로 닫기 + dlg.KeyDown += (_, e) => { if (e.Key == System.Windows.Input.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 = CreatePlaceholder("예: 코드 리뷰 후 알림", 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 = CreatePlaceholder("예: 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 { Width = new GridLength(1, GridUnitType.Star) }); + 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 = CreatePlaceholder("예: 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); + Grid.SetColumn(pathInnerGrid, 0); + 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, + }; + browseBtn.Child = new TextBlock { Text = "...", FontSize = 13, Foreground = accentBrush }; + browseBtn.MouseLeftButtonUp += (_, _) => + { + var ofd = new Microsoft.Win32.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 = CreatePlaceholder("예: --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, + }; + cancelBorder.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, + }; + saveBorder.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 Models.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 = _vm.Service.Settings.Llm.AgentHooks; + if (isNew) + hooks.Add(entry); + else if (index >= 0 && index < hooks.Count) + hooks[index] = entry; + + BuildHookCards(); + dlg.Close(); + }; + btnRow.Children.Add(saveBorder); + stack.Children.Add(btnRow); + + border.Child = stack; + dlg.Content = border; + dlg.ShowDialog(); + } + + private void BuildHookCards() + { + if (HookListPanel == null) return; + HookListPanel.Children.Clear(); + + var hooks = _vm.Service.Settings.Llm.AgentHooks; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + + 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.LightGray, + CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 4), + }; + + 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.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, + }; + var capturedHook = hook; + toggle.Checked += (_, _) => capturedHook.Enabled = true; + toggle.Unchecked += (_, _) => capturedHook.Enabled = false; + Grid.SetColumn(toggle, 0); + grid.Children.Add(toggle); + + // 정보 + var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var timingBadge = hook.Timing == "pre" ? "PRE" : "POST"; + var timingColor = hook.Timing == "pre" ? "#FF9800" : "#4CAF50"; + var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; + headerPanel.Children.Add(new TextBlock + { + Text = hook.Name, FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, + }); + headerPanel.Children.Add(new Border + { + Background = ThemeResourceHelper.HexBrush(timingColor), + CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock { Text = timingBadge, FontSize = 9, Foreground = Brushes.White, FontWeight = FontWeights.Bold }, + }); + if (hook.ToolName != "*") + { + headerPanel.Children.Add(new Border + { + Background = new SolidColorBrush(Color.FromArgb(40, 100, 100, 255)), + CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock { Text = hook.ToolName, FontSize = 9, Foreground = accentBrush }, + }); + } + info.Children.Add(headerPanel); + info.Children.Add(new TextBlock + { + Text = System.IO.Path.GetFileName(hook.ScriptPath), + FontSize = 11, Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 200, + }); + Grid.SetColumn(info, 1); + grid.Children.Add(info); + + // 편집 버튼 + var editBtn = new Border + { + Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 4, 0), Padding = new Thickness(6), + }; + editBtn.Child = new TextBlock + { + Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = secondaryText, + }; + editBtn.MouseLeftButtonUp += (_, _) => ShowHookEditDialog(hooks[idx], idx); + Grid.SetColumn(editBtn, 2); + grid.Children.Add(editBtn); + + // 삭제 버튼 + var delBtn = new Border + { + Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 0, 0), Padding = new Thickness(6), + }; + delBtn.Child = new TextBlock + { + Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), + }; + delBtn.MouseLeftButtonUp += (_, _) => + { + hooks.RemoveAt(idx); + BuildHookCards(); + }; + Grid.SetColumn(delBtn, 3); + grid.Children.Add(delBtn); + + card.Child = grid; + HookListPanel.Children.Add(card); + } + } + + // ─── MCP 서버 관리 ───────────────────────────────────────────────── + private void BtnAddMcpServer_Click(object sender, RoutedEventArgs e) + { + var dlg = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server"); + dlg.Owner = this; + if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.ResponseText)) return; + + var name = dlg.ResponseText.Trim(); + var cmdDlg = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @anthropic/mcp-server"); + cmdDlg.Owner = this; + if (cmdDlg.ShowDialog() != true || string.IsNullOrWhiteSpace(cmdDlg.ResponseText)) return; + + var entry = new Models.McpServerEntry { Name = name, Command = cmdDlg.ResponseText.Trim(), Enabled = true }; + _vm.Service.Settings.Llm.McpServers.Add(entry); + BuildMcpServerCards(); + } + + private void BuildMcpServerCards() + { + if (McpServerListPanel == null) return; + McpServerListPanel.Children.Clear(); + + var servers = _vm.Service.Settings.Llm.McpServers; + var primaryText = TryFindResource("PrimaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Blue; + + for (int i = 0; i < servers.Count; i++) + { + var srv = servers[i]; + var idx = i; + + var card = new Border + { + Background = TryFindResource("ItemBackground") as System.Windows.Media.Brush, + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 10, 14, 10), + Margin = new Thickness(0, 4, 0, 0), + BorderBrush = TryFindResource("BorderColor") as System.Windows.Media.Brush, + BorderThickness = new Thickness(1), + }; + + 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 { VerticalAlignment = VerticalAlignment.Center }; + info.Children.Add(new TextBlock + { + Text = srv.Name, FontSize = 13.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText, + }); + var detailSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; + detailSp.Children.Add(new Border + { + Background = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray, + CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 1, 6, 1), Margin = new Thickness(0, 0, 8, 0), Opacity = 0.8, + Child = new TextBlock { Text = srv.Enabled ? "활성" : "비활성", FontSize = 10, Foreground = System.Windows.Media.Brushes.White, FontWeight = FontWeights.SemiBold }, + }); + detailSp.Children.Add(new TextBlock + { + Text = $"{srv.Command} {string.Join(" ", srv.Args)}", FontSize = 11, + Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, + MaxWidth = 300, TextTrimming = TextTrimming.CharacterEllipsis, + }); + info.Children.Add(detailSp); + Grid.SetColumn(info, 0); + grid.Children.Add(info); + + var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + + // 활성/비활성 토글 + var toggleBtn = new Button + { + Content = srv.Enabled ? "\uE73E" : "\uE711", + FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), + FontSize = 12, ToolTip = srv.Enabled ? "비활성화" : "활성화", + Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0), + Foreground = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray, + Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand, + }; + toggleBtn.Click += (_, _) => { servers[idx].Enabled = !servers[idx].Enabled; BuildMcpServerCards(); }; + btnPanel.Children.Add(toggleBtn); + + // 삭제 + var delBtn = new Button + { + Content = "\uE74D", FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), + FontSize = 12, ToolTip = "삭제", + Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0), + Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xDD, 0x44, 0x44)), + Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand, + }; + delBtn.Click += (_, _) => { servers.RemoveAt(idx); BuildMcpServerCards(); }; + btnPanel.Children.Add(delBtn); + + Grid.SetColumn(btnPanel, 1); + grid.Children.Add(btnPanel); + card.Child = grid; + McpServerListPanel.Children.Add(card); + } + } + + // ─── 감사 로그 폴더 열기 ──────────────────────────────────────────── + private void BtnOpenAuditLog_Click(object sender, RoutedEventArgs e) + { + try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { } + } + + // ─── 폴백/MCP 텍스트 박스 로드/저장 ─────────────────────────────────── + private void BuildFallbackModelsPanel() + { + if (FallbackModelsPanel == null) return; + FallbackModelsPanel.Children.Clear(); + + var llm = _vm.Service.Settings.Llm; + var fallbacks = llm.FallbackModels; + var toggleStyle = TryFindResource("ToggleSwitch") as Style; + + // 서비스별로 모델 수집 (순서 고정: Ollama → vLLM → Gemini → Claude) + var sections = new (string Service, string Label, string Color, List Models)[] + { + ("ollama", "Ollama", "#107C10", new()), + ("vllm", "vLLM", "#0078D4", new()), + ("gemini", "Gemini", "#4285F4", new()), + ("claude", "Claude", "#8B5CF6", new()), + }; + + // RegisteredModels → ViewModel과 AppSettings 양쪽에서 수집 (저장 전에도 반영) + // 1) ViewModel의 RegisteredModels (UI에서 방금 추가한 것 포함) + foreach (var row in _vm.RegisteredModels) + { + var svc = (row.Service ?? "").ToLowerInvariant(); + var modelName = !string.IsNullOrEmpty(row.Alias) ? row.Alias : row.EncryptedModelName; + var section = sections.FirstOrDefault(s => s.Service == svc); + if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName)) + section.Models.Add(modelName); + } + // 2) AppSettings의 RegisteredModels (기존 저장된 것 — ViewModel에 없는 경우 보완) + foreach (var m in llm.RegisteredModels) + { + var svc = (m.Service ?? "").ToLowerInvariant(); + var modelName = !string.IsNullOrEmpty(m.Alias) ? m.Alias : m.EncryptedModelName; + var section = sections.FirstOrDefault(s => s.Service == svc); + if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName)) + section.Models.Add(modelName); + } + + // 현재 활성 모델 추가 (중복 제거) + if (!string.IsNullOrEmpty(llm.OllamaModel) && !sections[0].Models.Contains(llm.OllamaModel)) + sections[0].Models.Add(llm.OllamaModel); + if (!string.IsNullOrEmpty(llm.VllmModel) && !sections[1].Models.Contains(llm.VllmModel)) + sections[1].Models.Add(llm.VllmModel); + + // Gemini/Claude 고정 모델 목록 + foreach (var gm in new[] { "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash" }) + if (!sections[2].Models.Contains(gm)) sections[2].Models.Add(gm); + foreach (var cm in new[] { "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "claude-sonnet-4-5" }) + if (!sections[3].Models.Contains(cm)) sections[3].Models.Add(cm); + + // 렌더링 — 모델이 없는 섹션도 헤더는 표시 + foreach (var (service, svcLabel, svcColor, models) in sections) + { + FallbackModelsPanel.Children.Add(new TextBlock + { + Text = svcLabel, + FontSize = 11, FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(svcColor), + Margin = new Thickness(0, 8, 0, 4), + }); + + if (models.Count == 0) + { + FallbackModelsPanel.Children.Add(new TextBlock + { + Text = "등록된 모델 없음", + FontSize = 11, Foreground = Brushes.Gray, FontStyle = FontStyles.Italic, + Margin = new Thickness(8, 2, 0, 4), + }); + continue; + } + + foreach (var modelName in models) + { + var fullKey = $"{service}:{modelName}"; + + var row = new Grid { Margin = new Thickness(8, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var label = new TextBlock + { + Text = modelName, FontSize = 12, FontFamily = ThemeResourceHelper.ConsolasCourierNew, + VerticalAlignment = VerticalAlignment.Center, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, + }; + Grid.SetColumn(label, 0); + row.Children.Add(label); + + var captured = fullKey; + var cb = new CheckBox + { + IsChecked = fallbacks.Contains(fullKey, StringComparer.OrdinalIgnoreCase), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + if (toggleStyle != null) cb.Style = toggleStyle; + cb.Checked += (_, _) => + { + if (!fallbacks.Contains(captured)) fallbacks.Add(captured); + FallbackModelsBox.Text = string.Join("\n", fallbacks); + }; + cb.Unchecked += (_, _) => + { + fallbacks.RemoveAll(x => x.Equals(captured, StringComparison.OrdinalIgnoreCase)); + FallbackModelsBox.Text = string.Join("\n", fallbacks); + }; + Grid.SetColumn(cb, 1); + row.Children.Add(cb); + + FallbackModelsPanel.Children.Add(row); + } + } + } + + private void LoadAdvancedSettings() + { + var llm = _vm.Service.Settings.Llm; + if (FallbackModelsBox != null) + FallbackModelsBox.Text = string.Join("\n", llm.FallbackModels); + BuildFallbackModelsPanel(); + if (McpServersBox != null) + { + try + { + var json = System.Text.Json.JsonSerializer.Serialize(llm.McpServers, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + McpServersBox.Text = json; + } + catch (Exception) { McpServersBox.Text = "[]"; } + } + BuildMcpServerCards(); + BuildHookCards(); + } + + private void SaveAdvancedSettings() + { + var llm = _vm.Service.Settings.Llm; + if (FallbackModelsBox != null) + { + llm.FallbackModels = FallbackModelsBox.Text + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + } + if (McpServersBox != null && !string.IsNullOrWhiteSpace(McpServersBox.Text)) + { + try + { + llm.McpServers = System.Text.Json.JsonSerializer.Deserialize>( + McpServersBox.Text) ?? new(); + } + catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ } + } + + // 도구 비활성 목록 저장 + if (_toolCardsLoaded) + llm.DisabledTools = _disabledTools.ToList(); + } +} diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs new file mode 100644 index 0000000..8b93ab0 --- /dev/null +++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs @@ -0,0 +1,667 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class WorkflowAnalyzerWindow +{ + // ─── 병목 분석 차트 렌더링 ───────────────────────────────────── + + private void ClearBottleneckCharts() + { + WaterfallCanvas.Children.Clear(); + ToolTimePanel.Children.Clear(); + TokenTrendCanvas.Children.Clear(); + } + + private void RenderBottleneckCharts() + { + ClearBottleneckCharts(); + RenderWaterfallChart(); + RenderToolTimeChart(); + RenderTokenTrendChart(); + } + + /// 워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색. + private void RenderWaterfallChart() + { + if (_waterfallEntries.Count == 0) + { + WaterfallCanvas.Height = 40; + var emptyMsg = new TextBlock + { + Text = "실행 데이터가 없습니다", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }; + Canvas.SetLeft(emptyMsg, 10); + Canvas.SetTop(emptyMsg, 10); + WaterfallCanvas.Children.Add(emptyMsg); + return; + } + + // 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황 + var avgDuration = _waterfallEntries.Average(e => e.DurationMs); + var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs); + if (maxEndMs <= 0) maxEndMs = 1; + + var rowHeight = 22; + var labelWidth = 90.0; + var chartHeight = _waterfallEntries.Count * rowHeight + 10; + WaterfallCanvas.Height = Math.Max(chartHeight, 60); + + var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480; + var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간 + + for (int i = 0; i < _waterfallEntries.Count; i++) + { + var entry = _waterfallEntries[i]; + var y = i * rowHeight + 4; + + // 색상 결정 + Color barColor; + if (entry.DurationMs >= avgDuration * 2.0) + barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목) + else if (entry.DurationMs >= avgDuration * 1.5) + barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의) + else if (entry.Type == "llm") + barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM) + else + barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구) + + // 라벨 + var label = new TextBlock + { + Text = Truncate(entry.Label, 12), + FontSize = 10, + FontFamily = ThemeResourceHelper.Consolas, + Foreground = new SolidColorBrush(barColor), + Width = labelWidth, + TextAlignment = TextAlignment.Right, + }; + Canvas.SetLeft(label, 0); + Canvas.SetTop(label, y + 2); + WaterfallCanvas.Children.Add(label); + + // 바 + var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth; + var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3); + + var bar = new Rectangle + { + Width = barWidth, + Height = 14, + RadiusX = 3, RadiusY = 3, + Fill = new SolidColorBrush(barColor), + Opacity = 0.85, + ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}", + }; + Canvas.SetLeft(bar, labelWidth + 8 + barStart); + Canvas.SetTop(bar, y + 1); + WaterfallCanvas.Children.Add(bar); + + // 시간 텍스트 + var timeText = new TextBlock + { + Text = FormatMs(entry.DurationMs), + FontSize = 9, + Foreground = new SolidColorBrush(barColor), + FontFamily = ThemeResourceHelper.Consolas, + }; + Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4); + Canvas.SetTop(timeText, y + 3); + WaterfallCanvas.Children.Add(timeText); + + // 병목 아이콘 + if (entry.DurationMs >= avgDuration * 2.0) + { + var icon = new TextBlock + { + Text = "🔴", + FontSize = 9, + }; + Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44); + Canvas.SetTop(icon, y + 2); + WaterfallCanvas.Children.Add(icon); + } + } + } + + /// 도구별 누적 소요시간 수평 바 차트. + private void RenderToolTimeChart() + { + ToolTimePanel.Children.Clear(); + + if (_toolTimeAccum.Count == 0) + { + ToolTimePanel.Children.Add(new TextBlock + { + Text = "도구 실행 데이터가 없습니다", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(0, 4, 0, 4), + }); + return; + } + + // LLM 시간 합산 + var llmTime = _waterfallEntries + .Where(e => e.Type == "llm") + .Sum(e => e.DurationMs); + var allEntries = _toolTimeAccum + .Select(kv => (Name: kv.Key, Ms: kv.Value)) + .ToList(); + if (llmTime > 0) + allEntries.Add(("LLM 호출", llmTime)); + + var sorted = allEntries.OrderByDescending(e => e.Ms).ToList(); + var maxMs = sorted.Max(e => e.Ms); + if (maxMs <= 0) maxMs = 1; + + foreach (var (name, ms) in sorted) + { + var isBottleneck = ms == sorted[0].Ms; + var barColor = name == "LLM 호출" + ? Color.FromRgb(0x60, 0xA5, 0xFA) + : isBottleneck + ? Color.FromRgb(0xEF, 0x44, 0x44) + : Color.FromRgb(0x34, 0xD3, 0x99); + + var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) }); + + // 도구명 + var nameText = new TextBlock + { + Text = Truncate(name, 12), + FontSize = 11, + FontFamily = ThemeResourceHelper.Consolas, + Foreground = new SolidColorBrush(barColor), + TextAlignment = TextAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }; + Grid.SetColumn(nameText, 0); + + // 바 + var barWidth = (ms / (double)maxMs); + var barBorder = new Border + { + Background = new SolidColorBrush(barColor), + CornerRadius = new CornerRadius(3), + Height = 14, + HorizontalAlignment = HorizontalAlignment.Left, + Width = 0, // 나중에 SizeChanged에서 설정 + Opacity = 0.8, + }; + var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) }; + barContainer.Child = barBorder; + barContainer.SizeChanged += (_, _) => + { + barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4); + }; + Grid.SetColumn(barContainer, 1); + + // 시간 + var timeText = new TextBlock + { + Text = FormatMs(ms), + FontSize = 10, + Foreground = new SolidColorBrush(barColor), + FontFamily = ThemeResourceHelper.Consolas, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0), + }; + Grid.SetColumn(timeText, 2); + + row.Children.Add(nameText); + row.Children.Add(barContainer); + row.Children.Add(timeText); + ToolTimePanel.Children.Add(row); + } + } + + /// 반복별 토큰 추세 꺾은선 그래프. + private void RenderTokenTrendChart() + { + TokenTrendCanvas.Children.Clear(); + + if (_tokenTrend.Count < 2) + { + TokenTrendCanvas.Height = 40; + TokenTrendCanvas.Children.Add(new TextBlock + { + Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }); + Canvas.SetLeft(TokenTrendCanvas.Children[0], 10); + Canvas.SetTop(TokenTrendCanvas.Children[0], 10); + return; + } + + TokenTrendCanvas.Height = 100; + var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480; + var canvasHeight = 90.0; + var marginLeft = 45.0; + var marginRight = 10.0; + var chartWidth = canvasWidth - marginLeft - marginRight; + var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList(); + var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output)); + if (maxToken <= 0) maxToken = 1; + + // Y축 눈금 + for (int i = 0; i <= 2; i++) + { + var y = canvasHeight * (1 - i / 2.0); + var val = maxToken * i / 2; + var gridLine = new Line + { + X1 = marginLeft, Y1 = y, + X2 = canvasWidth - marginRight, Y2 = y, + Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + StrokeThickness = 0.5, + Opacity = 0.4, + }; + TokenTrendCanvas.Children.Add(gridLine); + + var yLabel = new TextBlock + { + Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(), + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextAlignment = TextAlignment.Right, + Width = marginLeft - 6, + }; + Canvas.SetLeft(yLabel, 0); + Canvas.SetTop(yLabel, y - 6); + TokenTrendCanvas.Children.Add(yLabel); + } + + // 입력 토큰 선 (파랑) + DrawPolyline(sorted.Select((t, i) => new Point( + marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, + canvasHeight * (1 - t.Input / (double)maxToken) + )).ToList(), "#3B82F6"); + + // 출력 토큰 선 (녹색) + DrawPolyline(sorted.Select((t, i) => new Point( + marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, + canvasHeight * (1 - t.Output / (double)maxToken) + )).ToList(), "#10B981"); + + // X축 라벨 + foreach (var (iter, _, _) in sorted) + { + var idx = sorted.FindIndex(t => t.Iteration == iter); + var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth; + var xLabel = new TextBlock + { + Text = $"#{iter}", + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }; + Canvas.SetLeft(xLabel, x - 8); + Canvas.SetTop(xLabel, canvasHeight + 2); + TokenTrendCanvas.Children.Add(xLabel); + } + + // 범례 + var legend = new StackPanel { Orientation = Orientation.Horizontal }; + legend.Children.Add(CreateLegendDot("#3B82F6", "입력")); + legend.Children.Add(CreateLegendDot("#10B981", "출력")); + Canvas.SetRight(legend, 10); + Canvas.SetTop(legend, 0); + TokenTrendCanvas.Children.Add(legend); + + // 컨텍스트 폭발 경고 + if (sorted.Count >= 3) + { + var inputValues = sorted.Select(t => t.Input).ToList(); + bool increasing = true; + for (int i = 1; i < inputValues.Count; i++) + { + if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; } + } + if (increasing && inputValues[^1] > inputValues[0] * 2) + { + var warning = new TextBlock + { + Text = "⚠ 컨텍스트 크기 급증", + FontSize = 10, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + FontWeight = FontWeights.SemiBold, + }; + Canvas.SetLeft(warning, marginLeft + 4); + Canvas.SetTop(warning, 0); + TokenTrendCanvas.Children.Add(warning); + } + } + } + + private void DrawPolyline(List points, string colorHex) + { + if (points.Count < 2) return; + var color = (Color)ColorConverter.ConvertFromString(colorHex); + + var polyline = new Polyline + { + Stroke = new SolidColorBrush(color), + StrokeThickness = 2, + StrokeLineJoin = PenLineJoin.Round, + }; + foreach (var p in points) + polyline.Points.Add(p); + TokenTrendCanvas.Children.Add(polyline); + + // 점 찍기 + foreach (var p in points) + { + var dot = new Ellipse + { + Width = 6, Height = 6, + Fill = new SolidColorBrush(color), + }; + Canvas.SetLeft(dot, p.X - 3); + Canvas.SetTop(dot, p.Y - 3); + TokenTrendCanvas.Children.Add(dot); + } + } + + private static StackPanel CreateLegendDot(string colorHex, string text) + { + var color = (Color)ColorConverter.ConvertFromString(colorHex); + var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) }; + sp.Children.Add(new Ellipse + { + Width = 6, Height = 6, + Fill = new SolidColorBrush(color), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 3, 0), + }); + sp.Children.Add(new TextBlock + { + Text = text, + FontSize = 9, + Foreground = new SolidColorBrush(color), + VerticalAlignment = VerticalAlignment.Center, + }); + return sp; + } + + // ─── 타임라인 노드 생성 ──────────────────────────────────────── + + private Border CreateTimelineNode(AgentEvent evt) + { + var (icon, iconColor, label) = GetEventVisual(evt); + + var node = new Border + { + Background = Brushes.Transparent, + Margin = new Thickness(0, 1, 0, 1), + Padding = new Thickness(8, 6, 8, 6), + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Tag = evt, + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + // 아이콘 원 + var iconBorder = new Border + { + Width = 22, Height = 22, + CornerRadius = new CornerRadius(11), + Background = new SolidColorBrush(iconColor), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 2, 0, 0), + }; + iconBorder.Child = new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconBorder, 0); + grid.Children.Add(iconBorder); + + // 타임라인 세로 선 + var line = new Rectangle + { + Width = 2, + Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Stretch, + Opacity = 0.4, + }; + Grid.SetColumn(line, 1); + grid.Children.Add(line); + + // 내용 + var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) }; + + var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; + headerPanel.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + }); + + if (evt.ElapsedMs > 0) + { + headerPanel.Children.Add(new TextBlock + { + Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms", + FontSize = 10, + Foreground = evt.ElapsedMs > 3000 + ? Brushes.OrangeRed + : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }); + } + + headerPanel.Children.Add(new TextBlock + { + Text = $" {evt.Timestamp:HH:mm:ss}", + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.6, + }); + content.Children.Add(headerPanel); + + if (!string.IsNullOrEmpty(evt.Summary)) + { + content.Children.Add(new TextBlock + { + Text = Truncate(evt.Summary, 120), + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0), + LineHeight = 16, + }); + } + + if (evt.InputTokens > 0 || evt.OutputTokens > 0) + { + var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; + tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6")); + tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981")); + content.Children.Add(tokenPanel); + } + + Grid.SetColumn(content, 2); + grid.Children.Add(content); + node.Child = grid; + + var hoverBrush = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + node.MouseEnter += (_, _) => node.Background = hoverBrush; + node.MouseLeave += (_, _) => node.Background = Brushes.Transparent; + node.MouseLeftButtonUp += (_, _) => ShowDetail(evt); + + return node; + } + + private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt) + { + return evt.Type switch + { + AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"), + AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"), + AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"), + AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"), + AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName), + AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"), + AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"), + AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"), + AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"), + AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"), + _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"), + }; + } + + private static Border CreateBadge(string text, string colorHex) + { + var color = (Color)ColorConverter.ConvertFromString(colorHex); + return new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(0, 0, 4, 0), + Child = new TextBlock + { + Text = text, + FontSize = 9, + Foreground = new SolidColorBrush(color), + FontWeight = FontWeights.SemiBold, + }, + }; + } + + // ─── 상세 패널 ───────────────────────────────────────────── + + private void ShowDetail(AgentEvent evt) + { + DetailPanel.Visibility = Visibility.Visible; + DetailPanel.Tag = evt; + var (_, color, label) = GetEventVisual(evt); + + DetailTitle.Text = label; + DetailBadge.Text = evt.Success ? "성공" : "실패"; + DetailBadge.Background = new SolidColorBrush(evt.Success + ? Color.FromRgb(0x34, 0xD3, 0x99) + : Color.FromRgb(0xF8, 0x71, 0x71)); + + var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}"; + if (evt.ElapsedMs > 0) + meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}"; + if (evt.InputTokens > 0 || evt.OutputTokens > 0) + meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}"; + if (evt.Iteration > 0) + meta += $" | 반복 #{evt.Iteration}"; + DetailMeta.Text = meta; + + var contentText = evt.Summary ?? ""; + if (!string.IsNullOrEmpty(evt.ToolInput)) + contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}"; + if (!string.IsNullOrEmpty(evt.FilePath)) + contentText += $"\n파일: {evt.FilePath}"; + DetailContent.Text = contentText; + } + + // ─── 요약 카드 업데이트 ────────────────────────────────────── + + private void UpdateSummaryCards() + { + var elapsed = (DateTime.Now - _startTime).TotalSeconds; + CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s"; + CardIterations.Text = _maxIteration.ToString(); + var totalTokens = _totalInputTokens + _totalOutputTokens; + CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString(); + CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString(); + CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString(); + CardToolCalls.Text = _totalToolCalls.ToString(); + } + + // ─── 유틸리티 ──────────────────────────────────────────────── + + private static string FormatMs(long ms) + => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms"; + + // ─── 윈도우 이벤트 ──────────────────────────────────────────── + + private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지) + var src = e.OriginalSource as DependencyObject; + while (src != null && src != sender) + { + if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear") + return; + src = VisualTreeHelper.GetParent(src); + } + if (e.ClickCount == 1) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide(); + private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized; + private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset(); + + private void TitleBtn_MouseEnter(object sender, MouseEventArgs e) + { + if (sender is Border b) + b.Background = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + } + + private void TitleBtn_MouseLeave(object sender, MouseEventArgs e) + { + if (sender is Border b) + b.Background = Brushes.Transparent; + } + + private static string Truncate(string text, int maxLen) + => text.Length <= maxLen ? text : text[..maxLen] + "…"; + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_NCHITTEST) + { + var pt = PointFromScreen(new Point( + (short)(lParam.ToInt32() & 0xFFFF), + (short)((lParam.ToInt32() >> 16) & 0xFFFF))); + const double grip = 8; + var w = ActualWidth; + var h = ActualHeight; + + if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } + if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } + if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } + if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } + if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } + if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } + if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } + if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } + } + return IntPtr.Zero; + } +} diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs index 0aab725..ced71fa 100644 --- a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs +++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs @@ -271,659 +271,4 @@ public partial class WorkflowAnalyzerWindow : Window } } - // ─── 병목 분석 차트 렌더링 ───────────────────────────────────── - - private void ClearBottleneckCharts() - { - WaterfallCanvas.Children.Clear(); - ToolTimePanel.Children.Clear(); - TokenTrendCanvas.Children.Clear(); - } - - private void RenderBottleneckCharts() - { - ClearBottleneckCharts(); - RenderWaterfallChart(); - RenderToolTimeChart(); - RenderTokenTrendChart(); - } - - /// 워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색. - private void RenderWaterfallChart() - { - if (_waterfallEntries.Count == 0) - { - WaterfallCanvas.Height = 40; - var emptyMsg = new TextBlock - { - Text = "실행 데이터가 없습니다", - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - }; - Canvas.SetLeft(emptyMsg, 10); - Canvas.SetTop(emptyMsg, 10); - WaterfallCanvas.Children.Add(emptyMsg); - return; - } - - // 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황 - var avgDuration = _waterfallEntries.Average(e => e.DurationMs); - var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs); - if (maxEndMs <= 0) maxEndMs = 1; - - var rowHeight = 22; - var labelWidth = 90.0; - var chartHeight = _waterfallEntries.Count * rowHeight + 10; - WaterfallCanvas.Height = Math.Max(chartHeight, 60); - - var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480; - var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간 - - for (int i = 0; i < _waterfallEntries.Count; i++) - { - var entry = _waterfallEntries[i]; - var y = i * rowHeight + 4; - - // 색상 결정 - Color barColor; - if (entry.DurationMs >= avgDuration * 2.0) - barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목) - else if (entry.DurationMs >= avgDuration * 1.5) - barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의) - else if (entry.Type == "llm") - barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM) - else - barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구) - - // 라벨 - var label = new TextBlock - { - Text = Truncate(entry.Label, 12), - FontSize = 10, - FontFamily = ThemeResourceHelper.Consolas, - Foreground = new SolidColorBrush(barColor), - Width = labelWidth, - TextAlignment = TextAlignment.Right, - }; - Canvas.SetLeft(label, 0); - Canvas.SetTop(label, y + 2); - WaterfallCanvas.Children.Add(label); - - // 바 - var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth; - var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3); - - var bar = new Rectangle - { - Width = barWidth, - Height = 14, - RadiusX = 3, RadiusY = 3, - Fill = new SolidColorBrush(barColor), - Opacity = 0.85, - ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}", - }; - Canvas.SetLeft(bar, labelWidth + 8 + barStart); - Canvas.SetTop(bar, y + 1); - WaterfallCanvas.Children.Add(bar); - - // 시간 텍스트 - var timeText = new TextBlock - { - Text = FormatMs(entry.DurationMs), - FontSize = 9, - Foreground = new SolidColorBrush(barColor), - FontFamily = ThemeResourceHelper.Consolas, - }; - Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4); - Canvas.SetTop(timeText, y + 3); - WaterfallCanvas.Children.Add(timeText); - - // 병목 아이콘 - if (entry.DurationMs >= avgDuration * 2.0) - { - var icon = new TextBlock - { - Text = "🔴", - FontSize = 9, - }; - Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44); - Canvas.SetTop(icon, y + 2); - WaterfallCanvas.Children.Add(icon); - } - } - } - - /// 도구별 누적 소요시간 수평 바 차트. - private void RenderToolTimeChart() - { - ToolTimePanel.Children.Clear(); - - if (_toolTimeAccum.Count == 0) - { - ToolTimePanel.Children.Add(new TextBlock - { - Text = "도구 실행 데이터가 없습니다", - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Margin = new Thickness(0, 4, 0, 4), - }); - return; - } - - // LLM 시간 합산 - var llmTime = _waterfallEntries - .Where(e => e.Type == "llm") - .Sum(e => e.DurationMs); - var allEntries = _toolTimeAccum - .Select(kv => (Name: kv.Key, Ms: kv.Value)) - .ToList(); - if (llmTime > 0) - allEntries.Add(("LLM 호출", llmTime)); - - var sorted = allEntries.OrderByDescending(e => e.Ms).ToList(); - var maxMs = sorted.Max(e => e.Ms); - if (maxMs <= 0) maxMs = 1; - - foreach (var (name, ms) in sorted) - { - var isBottleneck = ms == sorted[0].Ms; - var barColor = name == "LLM 호출" - ? Color.FromRgb(0x60, 0xA5, 0xFA) - : isBottleneck - ? Color.FromRgb(0xEF, 0x44, 0x44) - : Color.FromRgb(0x34, 0xD3, 0x99); - - var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) }); - - // 도구명 - var nameText = new TextBlock - { - Text = Truncate(name, 12), - FontSize = 11, - FontFamily = ThemeResourceHelper.Consolas, - Foreground = new SolidColorBrush(barColor), - TextAlignment = TextAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }; - Grid.SetColumn(nameText, 0); - - // 바 - var barWidth = (ms / (double)maxMs); - var barBorder = new Border - { - Background = new SolidColorBrush(barColor), - CornerRadius = new CornerRadius(3), - Height = 14, - HorizontalAlignment = HorizontalAlignment.Left, - Width = 0, // 나중에 SizeChanged에서 설정 - Opacity = 0.8, - }; - var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) }; - barContainer.Child = barBorder; - barContainer.SizeChanged += (_, _) => - { - barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4); - }; - Grid.SetColumn(barContainer, 1); - - // 시간 - var timeText = new TextBlock - { - Text = FormatMs(ms), - FontSize = 10, - Foreground = new SolidColorBrush(barColor), - FontFamily = ThemeResourceHelper.Consolas, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 0, 0), - }; - Grid.SetColumn(timeText, 2); - - row.Children.Add(nameText); - row.Children.Add(barContainer); - row.Children.Add(timeText); - ToolTimePanel.Children.Add(row); - } - } - - /// 반복별 토큰 추세 꺾은선 그래프. - private void RenderTokenTrendChart() - { - TokenTrendCanvas.Children.Clear(); - - if (_tokenTrend.Count < 2) - { - TokenTrendCanvas.Height = 40; - TokenTrendCanvas.Children.Add(new TextBlock - { - Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다", - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - }); - Canvas.SetLeft(TokenTrendCanvas.Children[0], 10); - Canvas.SetTop(TokenTrendCanvas.Children[0], 10); - return; - } - - TokenTrendCanvas.Height = 100; - var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480; - var canvasHeight = 90.0; - var marginLeft = 45.0; - var marginRight = 10.0; - var chartWidth = canvasWidth - marginLeft - marginRight; - var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList(); - var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output)); - if (maxToken <= 0) maxToken = 1; - - // Y축 눈금 - for (int i = 0; i <= 2; i++) - { - var y = canvasHeight * (1 - i / 2.0); - var val = maxToken * i / 2; - var gridLine = new Line - { - X1 = marginLeft, Y1 = y, - X2 = canvasWidth - marginRight, Y2 = y, - Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - StrokeThickness = 0.5, - Opacity = 0.4, - }; - TokenTrendCanvas.Children.Add(gridLine); - - var yLabel = new TextBlock - { - Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(), - FontSize = 9, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextAlignment = TextAlignment.Right, - Width = marginLeft - 6, - }; - Canvas.SetLeft(yLabel, 0); - Canvas.SetTop(yLabel, y - 6); - TokenTrendCanvas.Children.Add(yLabel); - } - - // 입력 토큰 선 (파랑) - DrawPolyline(sorted.Select((t, i) => new Point( - marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, - canvasHeight * (1 - t.Input / (double)maxToken) - )).ToList(), "#3B82F6"); - - // 출력 토큰 선 (녹색) - DrawPolyline(sorted.Select((t, i) => new Point( - marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, - canvasHeight * (1 - t.Output / (double)maxToken) - )).ToList(), "#10B981"); - - // X축 라벨 - foreach (var (iter, _, _) in sorted) - { - var idx = sorted.FindIndex(t => t.Iteration == iter); - var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth; - var xLabel = new TextBlock - { - Text = $"#{iter}", - FontSize = 9, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - }; - Canvas.SetLeft(xLabel, x - 8); - Canvas.SetTop(xLabel, canvasHeight + 2); - TokenTrendCanvas.Children.Add(xLabel); - } - - // 범례 - var legend = new StackPanel { Orientation = Orientation.Horizontal }; - legend.Children.Add(CreateLegendDot("#3B82F6", "입력")); - legend.Children.Add(CreateLegendDot("#10B981", "출력")); - Canvas.SetRight(legend, 10); - Canvas.SetTop(legend, 0); - TokenTrendCanvas.Children.Add(legend); - - // 컨텍스트 폭발 경고 - if (sorted.Count >= 3) - { - var inputValues = sorted.Select(t => t.Input).ToList(); - bool increasing = true; - for (int i = 1; i < inputValues.Count; i++) - { - if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; } - } - if (increasing && inputValues[^1] > inputValues[0] * 2) - { - var warning = new TextBlock - { - Text = "⚠ 컨텍스트 크기 급증", - FontSize = 10, - Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), - FontWeight = FontWeights.SemiBold, - }; - Canvas.SetLeft(warning, marginLeft + 4); - Canvas.SetTop(warning, 0); - TokenTrendCanvas.Children.Add(warning); - } - } - } - - private void DrawPolyline(List points, string colorHex) - { - if (points.Count < 2) return; - var color = (Color)ColorConverter.ConvertFromString(colorHex); - - var polyline = new Polyline - { - Stroke = new SolidColorBrush(color), - StrokeThickness = 2, - StrokeLineJoin = PenLineJoin.Round, - }; - foreach (var p in points) - polyline.Points.Add(p); - TokenTrendCanvas.Children.Add(polyline); - - // 점 찍기 - foreach (var p in points) - { - var dot = new Ellipse - { - Width = 6, Height = 6, - Fill = new SolidColorBrush(color), - }; - Canvas.SetLeft(dot, p.X - 3); - Canvas.SetTop(dot, p.Y - 3); - TokenTrendCanvas.Children.Add(dot); - } - } - - private static StackPanel CreateLegendDot(string colorHex, string text) - { - var color = (Color)ColorConverter.ConvertFromString(colorHex); - var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) }; - sp.Children.Add(new Ellipse - { - Width = 6, Height = 6, - Fill = new SolidColorBrush(color), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 3, 0), - }); - sp.Children.Add(new TextBlock - { - Text = text, - FontSize = 9, - Foreground = new SolidColorBrush(color), - VerticalAlignment = VerticalAlignment.Center, - }); - return sp; - } - - // ─── 타임라인 노드 생성 ──────────────────────────────────────── - - private Border CreateTimelineNode(AgentEvent evt) - { - var (icon, iconColor, label) = GetEventVisual(evt); - - var node = new Border - { - Background = Brushes.Transparent, - Margin = new Thickness(0, 1, 0, 1), - Padding = new Thickness(8, 6, 8, 6), - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Tag = evt, - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - // 아이콘 원 - var iconBorder = new Border - { - Width = 22, Height = 22, - CornerRadius = new CornerRadius(11), - Background = new SolidColorBrush(iconColor), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Top, - Margin = new Thickness(0, 2, 0, 0), - }; - iconBorder.Child = new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, - Foreground = Brushes.White, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(iconBorder, 0); - grid.Children.Add(iconBorder); - - // 타임라인 세로 선 - var line = new Rectangle - { - Width = 2, - Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Stretch, - Opacity = 0.4, - }; - Grid.SetColumn(line, 1); - grid.Children.Add(line); - - // 내용 - var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) }; - - var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; - headerPanel.Children.Add(new TextBlock - { - Text = label, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, - VerticalAlignment = VerticalAlignment.Center, - }); - - if (evt.ElapsedMs > 0) - { - headerPanel.Children.Add(new TextBlock - { - Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms", - FontSize = 10, - Foreground = evt.ElapsedMs > 3000 - ? Brushes.OrangeRed - : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - VerticalAlignment = VerticalAlignment.Center, - }); - } - - headerPanel.Children.Add(new TextBlock - { - Text = $" {evt.Timestamp:HH:mm:ss}", - FontSize = 9, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.6, - }); - content.Children.Add(headerPanel); - - if (!string.IsNullOrEmpty(evt.Summary)) - { - content.Children.Add(new TextBlock - { - Text = Truncate(evt.Summary, 120), - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 2, 0, 0), - LineHeight = 16, - }); - } - - if (evt.InputTokens > 0 || evt.OutputTokens > 0) - { - var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; - tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6")); - tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981")); - content.Children.Add(tokenPanel); - } - - Grid.SetColumn(content, 2); - grid.Children.Add(content); - node.Child = grid; - - var hoverBrush = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - node.MouseEnter += (_, _) => node.Background = hoverBrush; - node.MouseLeave += (_, _) => node.Background = Brushes.Transparent; - node.MouseLeftButtonUp += (_, _) => ShowDetail(evt); - - return node; - } - - private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt) - { - return evt.Type switch - { - AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"), - AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"), - AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"), - AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"), - AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName), - AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"), - AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"), - AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"), - AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"), - AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"), - _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"), - }; - } - - private static Border CreateBadge(string text, string colorHex) - { - var color = (Color)ColorConverter.ConvertFromString(colorHex); - return new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(0, 0, 4, 0), - Child = new TextBlock - { - Text = text, - FontSize = 9, - Foreground = new SolidColorBrush(color), - FontWeight = FontWeights.SemiBold, - }, - }; - } - - // ─── 상세 패널 ───────────────────────────────────────────── - - private void ShowDetail(AgentEvent evt) - { - DetailPanel.Visibility = Visibility.Visible; - DetailPanel.Tag = evt; - var (_, color, label) = GetEventVisual(evt); - - DetailTitle.Text = label; - DetailBadge.Text = evt.Success ? "성공" : "실패"; - DetailBadge.Background = new SolidColorBrush(evt.Success - ? Color.FromRgb(0x34, 0xD3, 0x99) - : Color.FromRgb(0xF8, 0x71, 0x71)); - - var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}"; - if (evt.ElapsedMs > 0) - meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}"; - if (evt.InputTokens > 0 || evt.OutputTokens > 0) - meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}"; - if (evt.Iteration > 0) - meta += $" | 반복 #{evt.Iteration}"; - DetailMeta.Text = meta; - - var contentText = evt.Summary ?? ""; - if (!string.IsNullOrEmpty(evt.ToolInput)) - contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}"; - if (!string.IsNullOrEmpty(evt.FilePath)) - contentText += $"\n파일: {evt.FilePath}"; - DetailContent.Text = contentText; - } - - // ─── 요약 카드 업데이트 ────────────────────────────────────── - - private void UpdateSummaryCards() - { - var elapsed = (DateTime.Now - _startTime).TotalSeconds; - CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s"; - CardIterations.Text = _maxIteration.ToString(); - var totalTokens = _totalInputTokens + _totalOutputTokens; - CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString(); - CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString(); - CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString(); - CardToolCalls.Text = _totalToolCalls.ToString(); - } - - // ─── 유틸리티 ──────────────────────────────────────────────── - - private static string FormatMs(long ms) - => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms"; - - // ─── 윈도우 이벤트 ──────────────────────────────────────────── - - private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지) - var src = e.OriginalSource as DependencyObject; - while (src != null && src != sender) - { - if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear") - return; - src = VisualTreeHelper.GetParent(src); - } - if (e.ClickCount == 1) DragMove(); - } - - private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide(); - private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized; - private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset(); - - private void TitleBtn_MouseEnter(object sender, MouseEventArgs e) - { - if (sender is Border b) - b.Background = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - } - - private void TitleBtn_MouseLeave(object sender, MouseEventArgs e) - { - if (sender is Border b) - b.Background = Brushes.Transparent; - } - - private static string Truncate(string text, int maxLen) - => text.Length <= maxLen ? text : text[..maxLen] + "…"; - - private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - if (msg == WM_NCHITTEST) - { - var pt = PointFromScreen(new Point( - (short)(lParam.ToInt32() & 0xFFFF), - (short)((lParam.ToInt32() >> 16) & 0xFFFF))); - const double grip = 8; - var w = ActualWidth; - var h = ActualHeight; - - if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } - if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } - if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } - if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } - if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } - if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } - if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } - if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } - } - return IntPtr.Zero; - } }