diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 939a1e0..e78abc1 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -87,7 +87,7 @@ --- -## Phase L3 — 차세대 런처 (v2.0) — 차기 개발 +## Phase L3 — 차세대 런처 (v2.0) — 진행 중 / 일부 완료 > **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되, > 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현. @@ -95,11 +95,11 @@ | # | 기능 | 설명 | 우선순위 | 교차 | |---|------|------|----------|------| -| L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 | -| L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 | -| L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 | -| L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 | -| L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, 태그 기반 검색 | 중간 | — | +| ✅ L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 | +| ✅ L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 | +| ✅ L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 | +| ✅ L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 | +| ✅ L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, `tag` 프리픽스로 태그 기반 검색. `file_tags.json` 로컬 저장 | 중간 | — | | L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 | | L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — | | L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 2f40050..b23618d 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -166,6 +166,8 @@ public partial class App : System.Windows.Application var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm); commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc)); commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm)); + // Phase L3-5: 파일 태그 시스템 + commandResolver.RegisterHandler(new TagHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/TagHandler.cs b/src/AxCopilot/Handlers/TagHandler.cs new file mode 100644 index 0000000..41809d2 --- /dev/null +++ b/src/AxCopilot/Handlers/TagHandler.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.IO; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다. +/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다. +/// +/// 사용법: +/// tag → 전체 태그 목록 (태그명 + 파일 수) +/// tag work → "work" 태그가 부여된 파일 목록 +/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가 +/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거 +/// tag clear C:\path\file.docx → 파일의 모든 태그 제거 +/// +public class TagHandler : IActionHandler +{ + public string? Prefix => "tag"; + + public PluginMetadata Metadata => new( + "FileTag", + "파일 태그 — tag", + "1.0", + "AX", + "파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다."); + + private static FileTagService Tags => FileTagService.Instance; + + // ─── GetItemsAsync ─────────────────────────────────────────────────────── + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + + // ── add 명령: tag add <태그> <경로> ──────────────────────────────── + if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase)) + { + var rest = q[4..].Trim(); + var spaceIdx = rest.IndexOf(' '); + if (spaceIdx > 0) + { + var tag = rest[..spaceIdx].Trim(); + var path = rest[(spaceIdx + 1)..].Trim(); + return Task.FromResult>( + [ + new LauncherItem( + $"태그 추가: [{tag}] → {Path.GetFileName(path)}", + $"{path} · Enter로 추가", + null, + ValueTuple.Create("__TAG_ADD__", tag, path), + Symbol: Symbols.Tag) + ]); + } + return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx"); + } + + // ── del 명령: tag del <태그> <경로> ──────────────────────────────── + if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase)) + { + var rest = q[4..].Trim(); + var spaceIdx = rest.IndexOf(' '); + if (spaceIdx > 0) + { + var tag = rest[..spaceIdx].Trim(); + var path = rest[(spaceIdx + 1)..].Trim(); + return Task.FromResult>( + [ + new LauncherItem( + $"태그 제거: [{tag}] ← {Path.GetFileName(path)}", + $"{path} · Enter로 제거", + null, + ValueTuple.Create("__TAG_DEL__", tag, path), + Symbol: Symbols.Delete) + ]); + } + return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx"); + } + + // ── clear 명령: tag clear <경로> ─────────────────────────────────── + if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase)) + { + var path = q[6..].Trim(); + if (!string.IsNullOrEmpty(path)) + { + var currentTags = Tags.GetTags(path); + var tagList = currentTags.Count > 0 + ? string.Join(", ", currentTags.Select(t => $"[{t}]")) + : "태그 없음"; + return Task.FromResult>( + [ + new LauncherItem( + $"태그 전체 제거: {Path.GetFileName(path)}", + $"{tagList} · Enter로 모두 삭제", + null, + ValueTuple.Create("__TAG_CLEAR__", path), + Symbol: Symbols.Delete) + ]); + } + return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx"); + } + + // ── 태그 검색 또는 전체 목록 ───────────────────────────────────────── + return string.IsNullOrEmpty(q) + ? BuildTagListItems() + : BuildTagSearchItems(q); + } + + // ─── ExecuteAsync ──────────────────────────────────────────────────────── + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + // 태그 추가 + if (item.Data is ValueTuple addCmd && + addCmd.Item1 == "__TAG_ADD__") + { + Tags.AddTag(addCmd.Item3, addCmd.Item2); + NotificationService.Notify("AX Copilot", + $"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}"); + return Task.CompletedTask; + } + + // 태그 제거 + if (item.Data is ValueTuple delCmd && + delCmd.Item1 == "__TAG_DEL__") + { + Tags.RemoveTag(delCmd.Item3, delCmd.Item2); + NotificationService.Notify("AX Copilot", + $"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}"); + return Task.CompletedTask; + } + + // 전체 태그 제거 + if (item.Data is ValueTuple clearCmd && + clearCmd.Item1 == "__TAG_CLEAR__") + { + Tags.ClearTags(clearCmd.Item2); + NotificationService.Notify("AX Copilot", + $"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}"); + return Task.CompletedTask; + } + + // 파일/폴더 열기 + if (item.Data is string filePath) + { + try + { + Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); + } + catch (Exception ex) + { + LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}"); + } + } + + return Task.CompletedTask; + } + + // ─── 내부 헬퍼 ────────────────────────────────────────────────────────── + + private static Task> BuildTagListItems() + { + var allTags = Tags.GetAllTags(); + if (allTags.Count == 0) + { + return Task.FromResult>( + [ + new LauncherItem( + "등록된 태그가 없습니다", + "tag add [태그] [경로]로 태그를 추가하세요", + null, null, Symbol: Symbols.Info), + new LauncherItem( + "사용법 보기", + "tag add work C:\\path\\file.docx / tag work / tag clear C:\\path", + null, null, Symbol: Symbols.Info), + ]); + } + + var items = allTags + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key) + .Take(12) + .Select(kv => new LauncherItem( + $"[{kv.Key}]", + $"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기", + null, null, + Symbol: Symbols.Tag)) + .ToList(); + + return Task.FromResult>(items); + } + + private static Task> BuildTagSearchItems(string q) + { + var allTags = Tags.GetAllTags(); + + // 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순 + var matchedTags = allTags.Keys + .Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)) + .OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(t => t) + .ToList(); + + if (!matchedTags.Any()) + { + return Task.FromResult>( + [ + new LauncherItem( + $"[{q}] 태그에 해당하는 파일 없음", + "tag add [태그] [경로]로 태그를 추가하세요", + null, null, Symbol: Symbols.Info) + ]); + } + + var items = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tag in matchedTags.Take(2)) + { + var files = Tags.GetFilesByTag(tag); + foreach (var path in files.Take(6)) + { + if (!seen.Add(path)) continue; + + var isDir = Directory.Exists(path); + var isFile = File.Exists(path); + var symbol = isDir ? Symbols.Folder + : isFile ? Symbols.File + : Symbols.Warning; + var hint = isDir ? "폴더 열기" + : isFile ? "파일 열기" + : "경로를 찾을 수 없음"; + + items.Add(new LauncherItem( + Path.GetFileName(path), + $"[{tag}] · {path} · {hint}", + null, path, + Symbol: symbol)); + } + } + + if (!items.Any()) + { + items.Add(new LauncherItem( + $"[{q}] 태그 파일 접근 불가", + "파일이 삭제되었거나 경로가 변경되었을 수 있습니다", + null, null, Symbol: Symbols.Warning)); + } + + return Task.FromResult>(items); + } + + private static Task> HelpItems(string usage, string example) => + Task.FromResult>( + [ + new LauncherItem( + $"사용법: {usage}", + example, + null, null, Symbol: Symbols.Info) + ]); +} diff --git a/src/AxCopilot/Services/FileTagService.cs b/src/AxCopilot/Services/FileTagService.cs new file mode 100644 index 0000000..dfa4f11 --- /dev/null +++ b/src/AxCopilot/Services/FileTagService.cs @@ -0,0 +1,155 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AxCopilot.Services; + +/// +/// Phase L3-5: 파일 태그 시스템 서비스. +/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다. +/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다. +/// +public class FileTagService +{ + private static readonly string TagFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "file_tags.json"); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + /// key = 정규화된 파일 경로, value = 태그 집합 + private Dictionary> _data = + new(StringComparer.OrdinalIgnoreCase); + + private bool _loaded; + + // ─── 싱글턴 ───────────────────────────────────────────────────────────── + private static FileTagService? _instance; + public static FileTagService Instance => _instance ??= new FileTagService(); + private FileTagService() { } + + // ─── 공개 API ──────────────────────────────────────────────────────────── + + /// 파일에 태그를 추가합니다. + public void AddTag(string path, string tag) + { + EnsureLoaded(); + path = NormalizePath(path); + tag = NormalizeTag(tag); + if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return; + + if (!_data.TryGetValue(path, out var tags)) + { + tags = new HashSet(StringComparer.OrdinalIgnoreCase); + _data[path] = tags; + } + tags.Add(tag); + Save(); + } + + /// 파일에서 태그를 제거합니다. + public void RemoveTag(string path, string tag) + { + EnsureLoaded(); + path = NormalizePath(path); + tag = NormalizeTag(tag); + if (!_data.TryGetValue(path, out var tags)) return; + tags.Remove(tag); + if (tags.Count == 0) _data.Remove(path); + Save(); + } + + /// 파일의 모든 태그를 제거합니다. + public void ClearTags(string path) + { + EnsureLoaded(); + path = NormalizePath(path); + _data.Remove(path); + Save(); + } + + /// 파일의 태그 목록을 반환합니다. + public IReadOnlyList GetTags(string path) + { + EnsureLoaded(); + path = NormalizePath(path); + return _data.TryGetValue(path, out var tags) + ? tags.OrderBy(t => t).ToList() + : Array.Empty(); + } + + /// 특정 태그가 부여된 파일 경로 목록을 반환합니다. + public IReadOnlyList GetFilesByTag(string tag) + { + EnsureLoaded(); + tag = NormalizeTag(tag); + return _data + .Where(kv => kv.Value.Contains(tag)) + .Select(kv => kv.Key) + .OrderBy(p => p) + .ToList(); + } + + /// 등록된 모든 태그와 각 파일 수를 반환합니다. + public IReadOnlyDictionary GetAllTags() + { + EnsureLoaded(); + return _data + .SelectMany(kv => kv.Value) + .GroupBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count()); + } + + /// 경로에 태그가 하나 이상 있는지 확인합니다. + public bool HasTags(string path) + { + EnsureLoaded(); + path = NormalizePath(path); + return _data.TryGetValue(path, out var tags) && tags.Count > 0; + } + + // ─── 내부 헬퍼 ────────────────────────────────────────────────────────── + + private void EnsureLoaded() + { + if (_loaded) return; + _loaded = true; + try + { + if (!File.Exists(TagFile)) return; + var raw = JsonSerializer.Deserialize>>( + File.ReadAllText(TagFile), JsonOpts); + if (raw != null) + { + _data = raw.ToDictionary( + kv => kv.Key, + kv => new HashSet(kv.Value, StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); + } + } + catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); } + } + + private void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!); + var raw = _data.ToDictionary( + kv => kv.Key, + kv => kv.Value.OrderBy(t => t).ToList()); + File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts)); + } + catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); } + } + + private static string NormalizePath(string path) + => path.Trim().TrimEnd('\\', '/'); + + private static string NormalizeTag(string tag) + => tag.Trim().ToLowerInvariant().Replace(" ", "-"); +} diff --git a/src/AxCopilot/Themes/Symbols.cs b/src/AxCopilot/Themes/Symbols.cs index ae5466f..b6c4a9a 100644 --- a/src/AxCopilot/Themes/Symbols.cs +++ b/src/AxCopilot/Themes/Symbols.cs @@ -130,4 +130,7 @@ internal static class Symbols public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일) public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일) public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조) + + // ─── Phase L3-5 태그 시스템 ──────────────────────────────────────────────── + public const string Tag = "\uEAB4"; // 파일 태그 (레이블) } diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index 3eec816..05d0021 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -147,6 +147,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged { "snap", ("스냅", Symbols.SnapLayout, "#B45309") }, { "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") }, { "help", ("도움말", Symbols.Info, "#6B7280") }, + // ─── Phase L3-5 파일 태그 ────────────────────────────────────────────── + { "tag", ("태그", Symbols.Tag, "#6366F1") }, }; // ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────