[Phase 43] 4개 대형 파일 파셜 클래스 분할
SettingsWindow.AgentConfig (1,202줄): - AgentConfig.cs → 608줄 (등록모델·스킬·프롬프트·AI토글·사내외모드) - AgentHooks.cs → 605줄 (에이전트훅·MCP서버·감사로그·폴백설정) ChatWindow.Presets (1,280줄): - Presets.cs → 315줄 (대화 주제 버튼) - CustomPresets.cs → 978줄 (커스텀 프리셋 관리·하단바·포맷메뉴) ChatWindow.PreviewAndFiles (1,105줄): - PreviewAndFiles.cs → 709줄 (미리보기 패널) - FileBrowser.cs → 408줄 (에이전트 진행률 바·파일 탐색기) WorkflowAnalyzerWindow (929줄): - WorkflowAnalyzerWindow.xaml.cs → 274줄 (리사이즈·탭·데이터수집) - WorkflowAnalyzerWindow.Charts.cs → 667줄 (차트·타임라인·패널·유틸) 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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차)
|
||||
|
||||
|
||||
978
src/AxCopilot/Views/ChatWindow.CustomPresets.cs
Normal file
978
src/AxCopilot/Views/ChatWindow.CustomPresets.cs
Normal file
@@ -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
|
||||
{
|
||||
// ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>커스텀 프리셋 추가 다이얼로그를 표시합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>컨텍스트 메뉴 항목을 생성합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
|
||||
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
|
||||
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
|
||||
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
|
||||
|
||||
/// <summary>하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
|
||||
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 자동",
|
||||
};
|
||||
|
||||
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
408
src/AxCopilot/Views/ChatWindow.FileBrowser.cs
Normal file
408
src/AxCopilot/Views/ChatWindow.FileBrowser.cs
Normal file
@@ -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<string> _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);
|
||||
}
|
||||
|
||||
/// <summary>에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -312,969 +312,4 @@ public partial class ChatWindow
|
||||
TopicButtonPanel.Children.Add(addBorder);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>커스텀 프리셋 추가 다이얼로그를 표시합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>컨텍스트 메뉴 항목을 생성합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
|
||||
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
|
||||
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
|
||||
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
|
||||
|
||||
/// <summary>하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
|
||||
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 자동",
|
||||
};
|
||||
|
||||
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> _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);
|
||||
}
|
||||
|
||||
/// <summary>에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>플레이스홀더(워터마크) TextBlock 생성 헬퍼.</summary>
|
||||
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<string> 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<List<Models.McpServerEntry>>(
|
||||
McpServersBox.Text) ?? new();
|
||||
}
|
||||
catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ }
|
||||
}
|
||||
|
||||
// 도구 비활성 목록 저장
|
||||
if (_toolCardsLoaded)
|
||||
llm.DisabledTools = _disabledTools.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
605
src/AxCopilot/Views/SettingsWindow.AgentHooks.cs
Normal file
605
src/AxCopilot/Views/SettingsWindow.AgentHooks.cs
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>플레이스홀더(워터마크) TextBlock 생성 헬퍼.</summary>
|
||||
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<string> 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<List<Models.McpServerEntry>>(
|
||||
McpServersBox.Text) ?? new();
|
||||
}
|
||||
catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ }
|
||||
}
|
||||
|
||||
// 도구 비활성 목록 저장
|
||||
if (_toolCardsLoaded)
|
||||
llm.DisabledTools = _disabledTools.ToList();
|
||||
}
|
||||
}
|
||||
667
src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs
Normal file
667
src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs
Normal file
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>도구별 누적 소요시간 수평 바 차트.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>반복별 토큰 추세 꺾은선 그래프.</summary>
|
||||
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<Point> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>도구별 누적 소요시간 수평 바 차트.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>반복별 토큰 추세 꺾은선 그래프.</summary>
|
||||
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<Point> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user