using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; /// 스킬 시각적 편집기. 새 스킬 생성 또는 기존 스킬 편집을 GUI로 지원합니다. public partial class SkillEditorWindow : Window { // ── 아이콘 후보 목록 (Segoe MDL2 Assets 코드포인트) ── private static readonly string[] IconCandidates = [ "\uE70F", // Edit "\uE8A5", // Document "\uE943", // Code "\uE74C", // Search "\uE8B7", // Folder "\uE896", // People "\uE713", // Settings "\uE753", // Cloud "\uE774", // Camera "\uE8D6", // Mail "\uE8F1", // Lightbulb "\uE7C3", // Data "\uECA7", // Rocket "\uE71E", // Chart "\uE8C8", // Copy "\uE8F6", // Process "\uE81E", // Link "\uEBD2", // AI "\uE9D9", // Globe "\uE77B", // Shield ]; private string _selectedIcon = "\uE70F"; private SkillDefinition? _editingSkill; private readonly ToolRegistry _toolRegistry; /// 새 스킬 모드로 열기. public SkillEditorWindow() { InitializeComponent(); _toolRegistry = ToolRegistry.CreateDefault(); Loaded += (_, _) => { BuildIconSelector(); BuildToolChecklist(); UpdatePreview(); }; } /// 편집 모드로 열기. public SkillEditorWindow(SkillDefinition skill) : this() { _editingSkill = skill; Loaded += (_, _) => LoadSkill(skill); } // ─── 타이틀바 ───────────────────────────────────────────────────────── private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 타이틀바 우측 닫기 버튼 영역에서는 DragMove 실행하지 않음 var pos = e.GetPosition(this); if (pos.X > ActualWidth - 50) return; if (e.ClickCount == 2) WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; else DragMove(); } private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); // ─── 아이콘 선택기 ──────────────────────────────────────────────────── private void BuildIconSelector() { IconSelectorPanel.Children.Clear(); var accentBrush = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var bgBrush = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; foreach (var icon in IconCandidates) { var isSelected = icon == _selectedIcon; var border = new Border { Width = 34, Height = 34, CornerRadius = new CornerRadius(8), Margin = new Thickness(0, 0, 4, 4), Cursor = Cursors.Hand, Background = isSelected ? accentBrush : bgBrush, BorderBrush = isSelected ? accentBrush : Brushes.Transparent, BorderThickness = new Thickness(1.5), Tag = icon, }; border.Child = new TextBlock { Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, Foreground = isSelected ? Brushes.White : subBrush, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; border.MouseLeftButtonUp += (s, _) => { if (s is Border b && b.Tag is string ic) { _selectedIcon = ic; BuildIconSelector(); } }; // 호버 var capturedIcon = icon; border.MouseEnter += (s, _) => { if (s is Border b && capturedIcon != _selectedIcon) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)); }; border.MouseLeave += (s, _) => { if (s is Border b && capturedIcon != _selectedIcon) b.Background = bgBrush; }; IconSelectorPanel.Children.Add(border); } } // ─── 도구 체크리스트 ────────────────────────────────────────────────── private void BuildToolChecklist() { ToolCheckListPanel.Children.Clear(); var tools = _toolRegistry.All.OrderBy(t => t.Name).ToList(); var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; // 기존 스킬 편집 시 활성 도구 파싱 var activeTools = new HashSet(StringComparer.OrdinalIgnoreCase); if (_editingSkill != null && !string.IsNullOrWhiteSpace(_editingSkill.AllowedTools)) { foreach (var t in _editingSkill.AllowedTools.Split(',', StringSplitOptions.RemoveEmptyEntries)) activeTools.Add(t.Trim()); } foreach (var tool in tools) { var cb = new CheckBox { Tag = tool.Name, IsChecked = activeTools.Count == 0 || activeTools.Contains(tool.Name), Margin = new Thickness(0, 0, 0, 4), Style = TryFindResource("ToggleSwitch") as Style, }; var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 2), }; row.Children.Add(cb); row.Children.Add(new TextBlock { Text = tool.Name, FontSize = 11.5, FontFamily = new FontFamily("Consolas"), Foreground = fgBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(6, 0, 0, 0), }); // 설명 툴팁 if (!string.IsNullOrEmpty(tool.Description)) row.ToolTip = tool.Description; ToolCheckListPanel.Children.Add(row); } } /// 체크된 도구 이름 목록을 가져옵니다. private List GetCheckedTools() { var result = new List(); foreach (var child in ToolCheckListPanel.Children) { if (child is StackPanel row && row.Children.Count > 0 && row.Children[0] is CheckBox cb) { if (cb.IsChecked == true && cb.Tag is string name) result.Add(name); } } return result; } // ─── 템플릿 삽입 ────────────────────────────────────────────────────── private void BtnInsertTemplate_Click(object sender, MouseButtonEventArgs e) { if (sender is not FrameworkElement el || el.Tag is not string tag) return; var template = tag switch { "tools" => BuildToolListTemplate(), "format" => """ ## 출력 형식 1. 결과를 사용자에게 한국어로 설명합니다. 2. 코드 블록은 마크다운 형식으로 감쌉니다. 3. 핵심 정보를 먼저 제시하고, 세부 사항은 뒤에 붙입니다. """, "steps" => """ ## 실행 단계 1. **분석**: 사용자의 요청을 분석합니다. 2. **계획**: 수행할 작업을 계획합니다. 3. **실행**: 도구를 사용하여 작업을 수행합니다. 4. **검증**: 결과를 검증합니다. 5. **보고**: 결과를 사용자에게 보고합니다. """, _ => "", }; if (string.IsNullOrEmpty(template)) return; var insertPos = TxtInstructions.CaretIndex; var prefix = insertPos > 0 && TxtInstructions.Text.Length > 0 && TxtInstructions.Text[insertPos - 1] != '\n' ? "\n\n" : ""; TxtInstructions.Text = TxtInstructions.Text.Insert(insertPos, prefix + template.Trim()); TxtInstructions.CaretIndex = insertPos + prefix.Length + template.Trim().Length; TxtInstructions.Focus(); } private string BuildToolListTemplate() { var checkedTools = GetCheckedTools(); if (checkedTools.Count == 0) return "## 사용 가능한 도구\n\n(도구가 선택되지 않았습니다. 우측 패널에서 도구를 선택하세요.)"; var lines = new List { "## 사용 가능한 도구", "" }; foreach (var toolName in checkedTools) { var tool = _toolRegistry.All.FirstOrDefault(t => t.Name == toolName); if (tool != null) lines.Add($"- **{tool.Name}**: {tool.Description}"); } return string.Join("\n", lines); } // ─── 미리보기 업데이트 ──────────────────────────────────────────────── private void TxtInstructions_TextChanged(object sender, TextChangedEventArgs e) => UpdatePreview(); private void UpdatePreview() { if (PreviewTokens == null || PreviewFileName == null) return; var content = GenerateSkillContent(); var tokens = TokenEstimator.Estimate(content); PreviewTokens.Text = $"예상 토큰: ~{tokens:N0}자 | {content.Length:N0}자"; var name = string.IsNullOrWhiteSpace(TxtName?.Text) ? "new-skill" : TxtName.Text.Trim(); PreviewFileName.Text = $"{name}.skill.md"; } // ─── 스킬 콘텐츠 생성 ──────────────────────────────────────────────── private string GenerateSkillContent() { var name = TxtName?.Text?.Trim() ?? "new-skill"; var label = TxtLabel?.Text?.Trim() ?? ""; var desc = TxtDescription?.Text?.Trim() ?? ""; var instructions = TxtInstructions?.Text ?? ""; // 런타임 요구사항 var requiresTag = ""; if (CmbRequires?.SelectedItem is ComboBoxItem cbi && cbi.Tag is string req && !string.IsNullOrEmpty(req)) requiresTag = req; // 도구 목록 var checkedTools = GetCheckedTools(); var allToolCount = _toolRegistry.All.Count; var allowedToolsStr = checkedTools.Count < allToolCount ? string.Join(", ", checkedTools) : ""; // 전체 선택이면 비워둠 // YAML 프론트매터 생성 var yaml = new List { "---" }; yaml.Add($"name: {name}"); if (!string.IsNullOrEmpty(label)) yaml.Add($"label: {label}"); if (!string.IsNullOrEmpty(desc)) yaml.Add($"description: {desc}"); yaml.Add($"icon: {_selectedIcon}"); if (!string.IsNullOrEmpty(requiresTag)) yaml.Add($"requires: {requiresTag}"); if (!string.IsNullOrEmpty(allowedToolsStr)) yaml.Add($"allowed-tools: {allowedToolsStr}"); yaml.Add("---"); yaml.Add(""); return string.Join("\n", yaml) + instructions; } // ─── 미리보기 버튼 ─────────────────────────────────────────────────── private void BtnPreview_Click(object sender, MouseButtonEventArgs e) { var content = GenerateSkillContent(); var previewWin = new Window { Title = "스킬 파일 미리보기", Width = 640, Height = 520, WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = Brushes.Transparent, WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this, }; var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var outerBorder = new Border { Background = bgBrush, CornerRadius = new CornerRadius(12), BorderBrush = borderBrush, BorderThickness = new Thickness(1), Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black, }, }; var grid = new Grid(); grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) }); grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 타이틀바 var titleBar = new Border { Background = itemBg, CornerRadius = new CornerRadius(12, 12, 0, 0), }; titleBar.MouseLeftButtonDown += (_, _) => previewWin.DragMove(); var titleText = new TextBlock { Text = "미리보기 — .skill.md", FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(16, 0, 0, 0), }; titleBar.Child = titleText; Grid.SetRow(titleBar, 0); // 콘텐츠 var textBox = new TextBox { Text = content, FontFamily = new FontFamily("Consolas, Cascadia Code, Segoe UI"), FontSize = 12.5, IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Background = itemBg, Foreground = fgBrush, BorderThickness = new Thickness(0), Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(8, 8, 8, 0), }; Grid.SetRow(textBox, 1); // 하단 var bottomBar = new Border { Padding = new Thickness(16, 10, 16, 10), CornerRadius = new CornerRadius(0, 0, 12, 12), }; var closeBtn = new Border { CornerRadius = new CornerRadius(8), Padding = new Thickness(18, 8, 18, 8), Background = itemBg, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, }; closeBtn.Child = new TextBlock { Text = "닫기", FontSize = 12.5, Foreground = subBrush, }; closeBtn.MouseLeftButtonUp += (_, _) => previewWin.Close(); closeBtn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; closeBtn.MouseLeave += (s, _) => { if (s is Border b) b.Background = itemBg; }; bottomBar.Child = closeBtn; Grid.SetRow(bottomBar, 2); grid.Children.Add(titleBar); grid.Children.Add(textBox); grid.Children.Add(bottomBar); outerBorder.Child = grid; previewWin.Content = outerBorder; previewWin.ShowDialog(); } // ─── 저장 ───────────────────────────────────────────────────────────── private void BtnSave_Click(object sender, MouseButtonEventArgs e) { // 유효성 검사 var name = TxtName.Text.Trim(); if (string.IsNullOrWhiteSpace(name)) { StatusText.Text = "⚠ 이름을 입력하세요."; StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); TxtName.Focus(); return; } // 영문 + 하이픈 + 숫자만 허용 if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z][a-zA-Z0-9\-]*$")) { StatusText.Text = "⚠ 이름은 영문으로 시작하며 영문, 숫자, 하이픈만 사용 가능합니다."; StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); TxtName.Focus(); return; } if (string.IsNullOrWhiteSpace(TxtInstructions.Text)) { StatusText.Text = "⚠ 지시사항을 입력하세요."; StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); TxtInstructions.Focus(); return; } var content = GenerateSkillContent(); // 저장 경로 결정 string savePath; if (_editingSkill != null) { // 편집 모드: 기존 파일 덮어쓰기 savePath = _editingSkill.FilePath; } else { // 새 스킬: 사용자 폴더에 저장 var userFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (!Directory.Exists(userFolder)) Directory.CreateDirectory(userFolder); savePath = Path.Combine(userFolder, $"{name}.skill.md"); // 파일 이름 충돌 시 숫자 추가 if (File.Exists(savePath)) { var counter = 2; while (File.Exists(Path.Combine(userFolder, $"{name}_{counter}.skill.md"))) counter++; savePath = Path.Combine(userFolder, $"{name}_{counter}.skill.md"); } } try { File.WriteAllText(savePath, content, System.Text.Encoding.UTF8); SkillService.LoadSkills(); StatusText.Text = $"✓ 저장 완료: {Path.GetFileName(savePath)}"; StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)); // 편집 결과 반환 DialogResult = true; } catch (Exception ex) { CustomMessageBox.Show($"저장 실패: {ex.Message}", "스킬 저장"); } } // ─── 편집 모드 로드 ─────────────────────────────────────────────────── private void LoadSkill(SkillDefinition skill) { TitleText.Text = "스킬 편집"; TxtName.Text = skill.Name; TxtLabel.Text = skill.Label; TxtDescription.Text = skill.Description; TxtInstructions.Text = skill.SystemPrompt; // 아이콘 선택 _selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0]; BuildIconSelector(); // 런타임 요구사항 for (int i = 0; i < CmbRequires.Items.Count; i++) { if (CmbRequires.Items[i] is ComboBoxItem item && item.Tag is string tag && string.Equals(tag, skill.Requires, StringComparison.OrdinalIgnoreCase)) { CmbRequires.SelectedIndex = i; break; } } // 도구 체크리스트 (BuildToolChecklist에서 이미 _editingSkill 기반으로 설정됨) UpdatePreview(); } }