Files
AX-Copilot-Codex/src/AxCopilot/Handlers/FlowHandler.cs
lacvet 0336904258 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개를 확인했습니다.
2026-04-05 00:59:45 +09:00

238 lines
10 KiB
C#

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 { }
}
}