AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
237
src/AxCopilot/Handlers/FlowHandler.cs
Normal file
237
src/AxCopilot/Handlers/FlowHandler.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L29-2: 명령 체인(워크플로우) 핸들러. "flow" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: flow → 등록된 플로우 목록
|
||||
/// flow add 출근준비 "remind 09:00 회의" > "today" > "todo list" → 플로우 추가
|
||||
/// flow 출근준비 → 플로우 실행
|
||||
/// flow del 출근준비 → 플로우 삭제
|
||||
/// flow edit 출근준비 → 플로우 명령 목록 표시 (클립보드 복사)
|
||||
/// Enter → 저장된 명령들을 순서대로 런처에 실행 (클립보드에 명령 목록 복사).
|
||||
/// Alfred 워크플로우 경량 대응.
|
||||
/// 저장: %APPDATA%\AxCopilot\flows.json
|
||||
/// </summary>
|
||||
public class FlowHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "flow";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"명령 체인",
|
||||
"여러 명령을 묶어 순서대로 실행 (워크플로우)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record FlowEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("commands")] List<string> Commands,
|
||||
[property: JsonPropertyName("created")] DateTime Created);
|
||||
|
||||
private static readonly string DataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "flows.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var flows = Load();
|
||||
|
||||
// ── add 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = q[4..].Trim();
|
||||
var spaceIdx = rest.IndexOf(' ');
|
||||
if (spaceIdx < 1)
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: flow add {이름} {명령1} > {명령2} > ...",
|
||||
"예: flow add 출근 \"today\" > \"todo list\" > \"remind 09:00 회의\"",
|
||||
null, null, Symbol: "\uE710"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var name = rest[..spaceIdx];
|
||||
var cmdStr = rest[(spaceIdx + 1)..].Trim();
|
||||
var commands = ParseCommands(cmdStr);
|
||||
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("명령을 > 로 구분해 입력하세요",
|
||||
"예: \"today\" > \"todo list\"",
|
||||
null, null, Symbol: Themes.Symbols.Warning));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"플로우 저장: {name} ({commands.Count}개 명령)",
|
||||
string.Join(" → ", commands),
|
||||
null, ("add", name, commands), Symbol: "\uE710"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── del 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = q[4..].Trim();
|
||||
var found = flows.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (found != null)
|
||||
{
|
||||
items.Add(new LauncherItem($"플로우 삭제: {found.Name}",
|
||||
$"{found.Commands.Count}개 명령 · {string.Join(" → ", found.Commands)}",
|
||||
null, ("del", found.Name), Symbol: "\uE74D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{name}' 플로우를 찾을 수 없습니다",
|
||||
"flow del {이름}", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (flows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("등록된 명령 체인이 없습니다",
|
||||
"flow add {이름} {명령1} > {명령2} > ... 로 추가하세요",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
items.Add(new LauncherItem("예시: flow add 출근 \"today\" > \"todo list\"",
|
||||
"오늘 업무 뷰 → 할일 목록 순서대로 실행",
|
||||
null, null, Symbol: Themes.Symbols.Info));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"명령 체인 {flows.Count}개",
|
||||
"Enter: 명령 목록 클립보드 복사 · flow add/del 로 관리",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
|
||||
foreach (var f in flows)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"▶ {f.Name} ({f.Commands.Count}단계)",
|
||||
string.Join(" → ", f.Commands),
|
||||
null, ("run", f), Symbol: "\uE768"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 이름 검색 → 실행 ──────────────────────────────────────────────────
|
||||
var match = flows.FirstOrDefault(f => f.Name.Equals(q, StringComparison.OrdinalIgnoreCase));
|
||||
if (match != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"▶ {match.Name} 실행",
|
||||
string.Join(" → ", match.Commands),
|
||||
null, ("run", match), Symbol: "\uE768"));
|
||||
|
||||
for (int i = 0; i < match.Commands.Count; i++)
|
||||
items.Add(new LauncherItem($" {i + 1}. {match.Commands[i]}", "",
|
||||
null, null, Symbol: Themes.Symbols.Terminal));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 부분 매칭
|
||||
var searched = flows.Where(f =>
|
||||
f.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Commands.Any(c => c.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
|
||||
if (searched.Count > 0)
|
||||
{
|
||||
foreach (var f in searched)
|
||||
items.Add(new LauncherItem($"▶ {f.Name} ({f.Commands.Count}단계)",
|
||||
string.Join(" → ", f.Commands),
|
||||
null, ("run", f), Symbol: "\uE768"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 플로우를 찾을 수 없습니다",
|
||||
"flow add {이름} {명령} > {명령} 으로 추가하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("add", string name, List<string> commands))
|
||||
{
|
||||
var flows = Load();
|
||||
flows.RemoveAll(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
flows.Add(new FlowEntry(name, commands, DateTime.Now));
|
||||
Save(flows);
|
||||
NotificationService.Notify("flow", $"'{name}' 플로우가 저장되었습니다. ({commands.Count}단계)");
|
||||
}
|
||||
else if (item.Data is ("del", string delName))
|
||||
{
|
||||
var flows = Load();
|
||||
flows.RemoveAll(f => f.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
|
||||
Save(flows);
|
||||
NotificationService.Notify("flow", $"'{delName}' 플로우가 삭제되었습니다.");
|
||||
}
|
||||
else if (item.Data is ("run", FlowEntry flow))
|
||||
{
|
||||
// 명령 목록을 클립보드에 복사 (사용자가 순서대로 런처에 입력)
|
||||
var text = string.Join("\n", flow.Commands.Select((c, i) => $"{i + 1}. {c}"));
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("flow", $"'{flow.Name}' 명령 {flow.Commands.Count}개가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 명령 파싱 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<string> ParseCommands(string input)
|
||||
{
|
||||
// "cmd1" > "cmd2" > "cmd3" 또는 cmd1 > cmd2 > cmd3
|
||||
return input.Split('>')
|
||||
.Select(s => s.Trim().Trim('"').Trim())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ─── JSON I/O ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<FlowEntry> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(DataPath)) return [];
|
||||
var json = File.ReadAllText(DataPath);
|
||||
return JsonSerializer.Deserialize<List<FlowEntry>>(json) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static void Save(List<FlowEntry> list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DataPath)!;
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user