스킬 시스템 설정에 프로젝트 스킬 탐색, 플러그인 스킬 탐색, 레거시 command 스킬 호환, inline shell 허용 여부와 시간/출력 제한을 추가하고 일반 설정 및 AX Agent 설정 UI에 연결했다. SkillService는 로드 시그니처에 실제 스킬 파일 수와 최근 수정 시각을 반영하도록 보강해 같은 폴더라도 스킬 파일이 바뀌면 다음 로드 요청에서 자동으로 재탐색되도록 정리했다. inline shell 실행기는 설정 기반 비활성화, timeout, 최대 출력 길이 제한을 적용하고 스킬 편집기/갤러리는 lazy prompt body 경로와 ReloadFromCurrentSettings()를 사용하도록 맞췄다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4b\\ -p:IntermediateOutputPath=obj\\verify_phase4b\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase4b_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4b_tests\\ (통과 18, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지)
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.ReloadFromCurrentSettings();
|
|
|
|
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 = SkillService.GetSkillPromptForDisplay(skill);
|
|
|
|
// 아이콘 선택
|
|
_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();
|
|
}
|
|
}
|