529 lines
21 KiB
C#
529 lines
21 KiB
C#
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();
|
|
}
|
|
}
|