Initial commit to new repository
This commit is contained in:
528
src/AxCopilot/Views/SkillEditorWindow.xaml.cs
Normal file
528
src/AxCopilot/Views/SkillEditorWindow.xaml.cs
Normal file
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
|
||||
/// <summary>스킬 시각적 편집기. 새 스킬 생성 또는 기존 스킬 편집을 GUI로 지원합니다.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>새 스킬 모드로 열기.</summary>
|
||||
public SkillEditorWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_toolRegistry = ToolRegistry.CreateDefault();
|
||||
Loaded += (_, _) => { BuildIconSelector(); BuildToolChecklist(); UpdatePreview(); };
|
||||
}
|
||||
|
||||
/// <summary>편집 모드로 열기.</summary>
|
||||
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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>체크된 도구 이름 목록을 가져옵니다.</summary>
|
||||
private List<string> GetCheckedTools()
|
||||
{
|
||||
var result = new List<string>();
|
||||
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<string> { "## 사용 가능한 도구", "" };
|
||||
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<string> { "---" };
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user