diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 7fe0940..7a368d0 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -231,7 +231,7 @@ | 17-C1 | **훅 이벤트 확장** | UserPromptSubmit, PreCompact/PostCompact, FileChanged, CwdChanged, SessionEnd, ConfigChange 추가 | 최고 | | 17-C2 | **훅 타입 확장** | type:prompt (LLM 보안검사), type:agent (에이전트 루프 검증) 추가 | 최고 | | 17-C3 | **훅 속성·출력 고도화** | if/once/async/statusMessage 속성. additionalContext·permissionDecision·updatedInput 출력 | 높음 | -| 17-C4 | **SessionStart 확장** | watchPaths (파일 감시 등록), initialUserMessage, additionalContext | 중간 | +| 17-C4 ✅ | **SessionStart 확장** | watchPaths → FileWatcherService 등록 완료. SessionStart 훅 결과의 WatchPaths가 FileChanged 훅을 fire-and-forget으로 트리거 | 중간 | ### Group D — 스킬 시스템 고도화 (CC 스킬 문서 기반) @@ -283,7 +283,7 @@ | # | 기능 | 설명 | 우선순위 | 갭 | |---|------|------|----------|----| -| 18-A1 | **코디네이터 에이전트 모드** | 계획·라우팅 전담, 구현은 서브에이전트 위임 | 최고 | G1 | +| 18-A1 ✅ | **코디네이터 에이전트 모드** | CoordinatorAgentService 구현 완료. LLM 계획 수립 → 의존성 기반 토폴로지 정렬 → 병렬 서브에이전트 실행 → 결과 합성. EnableCoordinatorMode 설정 연동. AgentLoopService.Coordinator.cs 파셜 분리 | 최고 | G1 | | 18-A2 ✅ | **Worktree 격리 서브에이전트** | WorktreeManager 구현 완료 + DelegateAgentTool에 주입 (isolation:"worktree" 파라미터 지원) | 최고 | — | | 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 | | 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — | @@ -293,7 +293,7 @@ | # | 기능 | 설명 | 우선순위 | 갭 | |---|------|------|----------|----| | 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 | -| 18-C1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 기반 인앱 갤러리. zip 설치 | 높음 | — | +| 18-C1 ✅ | **플러그인 갤러리 + 레지스트리** | PluginGalleryViewModel + PluginInstallService + SettingsWindow 플러그인 탭 구현 완료. zip 설치·활성화 토글·제거 UI 완비 | 높음 | — | | 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — | | 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — | | 18-C4 | **오프라인 AI (ONNX Runtime)** | 로컬 소형 모델, 별도 배포 | 낮음 | — | diff --git a/src/AxCopilot/Models/AppSettings.LlmSettings.cs b/src/AxCopilot/Models/AppSettings.LlmSettings.cs index 50ee873..97da4c8 100644 --- a/src/AxCopilot/Models/AppSettings.LlmSettings.cs +++ b/src/AxCopilot/Models/AppSettings.LlmSettings.cs @@ -368,6 +368,10 @@ public class LlmSettings [JsonPropertyName("enableDiffTracker")] public bool EnableDiffTracker { get; set; } = true; + /// Phase 18-A1: 코디네이터 에이전트 모드 활성화. true이면 LLM이 먼저 계획을 수립 후 서브에이전트에 위임. 기본 false. + [JsonPropertyName("enableCoordinatorMode")] + public bool EnableCoordinatorMode { get; set; } = false; + /// 추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용. [JsonPropertyName("skillsFolderPath")] public string SkillsFolderPath { get; set; } = ""; diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs new file mode 100644 index 0000000..8b4a883 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs @@ -0,0 +1,100 @@ +namespace AxCopilot.Services.Agent; + +/// +/// Phase 18-A1: 코디네이터 에이전트 모드. +/// LLM이 계획을 수립하고 서브에이전트들에게 태스크를 위임합니다. +/// +public partial class AgentLoopService +{ + /// + /// 코디네이터 모드 실행. + /// 성공하면 최종 합성 결과를 반환, 실패 시 null 반환 (일반 에이전트 루프로 fallback). + /// + internal async Task RunCoordinatorModeAsync( + string userRequest, + string workFolder, + CancellationToken ct) + { + try + { + var coordService = new CoordinatorAgentService(_llm); + + // 1단계: 계획 수립 + var reqPreview = userRequest.Length > 60 ? userRequest[..57] + "…" : userRequest; + EmitEvent(AgentEventType.Planning, "coordinator", + $"코디네이터 모드: 요청 분석 중... [{reqPreview}]"); + + var plan = await coordService.PlanAsync(userRequest, ct); + + if (plan.Tasks.Count == 0) + return null; // 계획 실패 → 일반 루프 fallback + + // 계획 요약 이벤트 + var planSummary = string.Join(", ", plan.Tasks.Select(t => $"{t.AgentType}:{t.Id}")); + EmitEvent(AgentEventType.Planning, "coordinator", + $"계획 수립 완료 ({plan.Tasks.Count}개 태스크): {planSummary}"); + + // 2단계: 사용자 승인 (UserDecisionCallback이 있는 경우) + if (UserDecisionCallback != null) + { + var planText = FormatCoordinatorPlan(plan); + var options = new List { "실행", "취소" }; + var answer = await UserDecisionCallback(planText, options); + + if (answer == null || answer.Contains("취소", StringComparison.OrdinalIgnoreCase)) + { + EmitEvent(AgentEventType.Complete, "coordinator", "사용자가 코디네이터 계획을 취소했습니다."); + return "코디네이터 계획이 취소되었습니다."; + } + } + + // 3단계: 각 서브태스크 실행 + var result = await coordService.ExecuteAsync( + plan, + workFolder, + RunSubAgentAsync, + onTaskStarted: (id, desc) => + EmitEvent(AgentEventType.ToolCall, $"coordinator/{id}", + $"[{id}] {desc}"), + onTaskCompleted: (id, res) => + EmitEvent(AgentEventType.ToolResult, $"coordinator/{id}", + $"[{id}] 완료: {(res.Length > 100 ? res[..97] + "…" : res)}"), + ct); + + EmitEvent(AgentEventType.Complete, "coordinator", "코디네이터 모드 완료"); + return result; + } + catch (OperationCanceledException) + { + EmitEvent(AgentEventType.Complete, "coordinator", "취소됨"); + return "코디네이터 모드가 취소되었습니다."; + } + catch (Exception ex) + { + LogService.Warn($"[Coordinator] 오류: {ex.Message}. 일반 에이전트 루프로 fallback."); + EmitEvent(AgentEventType.Error, "coordinator", $"코디네이터 오류: {ex.Message} — 일반 모드로 전환"); + return null; // fallback to normal loop + } + } + + /// 코디네이터 계획을 사용자에게 보여줄 텍스트로 포맷합니다. + private static string FormatCoordinatorPlan(CoordinatorPlan plan) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"📋 코디네이터 계획 ({plan.Tasks.Count}개 태스크)"); + sb.AppendLine($"요청: {plan.OriginalRequest}"); + sb.AppendLine(); + + foreach (var task in plan.Tasks) + { + var deps = task.Dependencies.Count > 0 + ? $" (선행: {string.Join(", ", task.Dependencies)})" + : ""; + sb.AppendLine($" [{task.Id}] {task.AgentType}{deps}"); + sb.AppendLine($" {task.Description}"); + } + + sb.AppendLine("\n이 계획을 실행할까요?"); + return sb.ToString(); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs index 918ee67..ed7f763 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs @@ -47,6 +47,32 @@ public partial class AgentLoopService var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm); ApplyExtendedHookResult(result, messages); + // Phase 17-C4: SessionStart 훅 WatchPaths → FileWatcherService 등록 + if (kind == HookEventKind.SessionStart && + result.WatchPaths != null && result.WatchPaths.Count > 0) + { + _fileWatcher.ClearAll(); // 이전 세션 감시 경로 초기화 + var workFolder = _settings.Settings.Llm.WorkFolder ?? ""; + foreach (var pathPattern in result.WatchPaths) + { + // 상대 경로는 workFolder 기준으로 해석 + var absPattern = System.IO.Path.IsPathRooted(pathPattern) + ? pathPattern + : System.IO.Path.Combine(workFolder, pathPattern); + _fileWatcher.Watch(absPattern, changedPath => + { + // FileChanged 훅 fire-and-forget + _ = RunExtendedEventAsync( + HookEventKind.FileChanged, null, CancellationToken.None, + changedFile: changedPath); + EmitEvent(AgentEventType.Thinking, "file_watcher", + $"파일 변경 감지: {System.IO.Path.GetFileName(changedPath)}"); + }); + } + EmitEvent(AgentEventType.Thinking, "file_watcher", + $"watchPaths 등록 완료 ({result.WatchPaths.Count}개 패턴)"); + } + // HookFired 이벤트 로그 기록 if (_eventLog != null && hooks.Count > 0) _ = _eventLog.AppendAsync(AgentEventLogType.HookFired, diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index a257f9b..792e9ce 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -78,6 +78,9 @@ public partial class AgentLoopService /// 위임 에이전트 도구 참조 (서브에이전트 실행기 주입용). private DelegateAgentTool? _delegateAgentTool; + /// Phase 17-C4: watchPaths 파일 감시 서비스. + private readonly FileWatcherService _fileWatcher = new(); + /// 일시정지 제어용 세마포어. 1이면 진행, 0이면 대기. private readonly SemaphoreSlim _pauseSemaphore = new(1, 1); @@ -224,6 +227,18 @@ public partial class AgentLoopService var context = BuildContext(activeTabSnapshot); + // Phase 18-A1: 코디네이터 에이전트 모드 — 계획 수립 후 서브에이전트 위임 + if (llm.EnableCoordinatorMode && !string.IsNullOrWhiteSpace(userQuery)) + { + var coordResult = await RunCoordinatorModeAsync(userQuery, llm.WorkFolder ?? "", ct); + if (coordResult != null) + { + IsRunning = false; + return coordResult; + } + // null 반환 시 일반 에이전트 루프로 fallback + } + // Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입 await InjectReflexionContextAsync(messages, userQuery); diff --git a/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs b/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs new file mode 100644 index 0000000..276ad2d --- /dev/null +++ b/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs @@ -0,0 +1,265 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 18-A1: 코디네이터 에이전트 서비스. +/// 사용자 요청을 분석하여 멀티 서브태스크 계획을 수립하고, +/// 각 태스크를 특화 서브에이전트에 위임하여 실행합니다. +/// +public class CoordinatorAgentService +{ + private readonly LlmService _llm; + + // 사용 가능한 에이전트 유형 + private static readonly string[] KnownAgentTypes = + { + "implementer", // 코드 작성·수정 + "researcher", // 조사·분석·요약 + "reviewer", // 코드·문서 리뷰 + "documenter", // 문서·보고서 작성 + "tester", // 테스트 생성·실행 + }; + + private const string PlanSystemPrompt = @"당신은 복잡한 작업을 분석하여 서브태스크로 분해하는 코디네이터 에이전트입니다. +사용자의 요청을 받아 실행 계획을 JSON 형식으로 반환하세요. + +사용 가능한 에이전트 유형: +- implementer: 코드 작성 및 수정 +- researcher: 조사, 분석, 요약 +- reviewer: 코드·문서 리뷰 +- documenter: 문서·보고서 작성 +- tester: 테스트 생성 및 실행 + +반환 형식 (JSON만 반환, 설명 없음): +{ + ""original_request"": ""원본 요청"", + ""tasks"": [ + { + ""id"": ""task1"", + ""agent_type"": ""researcher"", + ""description"": ""태스크 설명"", + ""dependencies"": [] + }, + { + ""id"": ""task2"", + ""agent_type"": ""implementer"", + ""description"": ""태스크 설명"", + ""dependencies"": [""task1""] + } + ] +} + +규칙: +- 독립적으로 실행 가능한 태스크는 의존성 없이 병렬 실행 가능 +- 선행 태스크 결과가 필요한 경우에만 dependencies에 추가 +- 최대 6개 태스크로 분해 +- JSON 외 다른 텍스트 없이 순수 JSON만 반환"; + + public CoordinatorAgentService(LlmService llm) + { + _llm = llm; + } + + /// 사용자 요청을 분석하여 CoordinatorPlan을 생성합니다. + public async Task PlanAsync(string request, CancellationToken ct) + { + var messages = new List + { + new() { Role = "system", Content = PlanSystemPrompt }, + new() { Role = "user", Content = $"다음 요청을 분석하여 실행 계획을 수립하세요:\n\n{request}" } + }; + + var response = await _llm.SendAsync(messages, ct); + return ParsePlan(request, response); + } + + /// + /// CoordinatorPlan을 실행합니다. 의존성 순서에 따라 태스크를 실행하고 + /// 각 태스크 결과를 후속 태스크에 컨텍스트로 전달합니다. + /// + /// 실행할 계획 + /// 작업 폴더 + /// 서브에이전트 실행기 (agentType, task, context, ct) → result + /// 태스크 시작 콜백 (taskId, description) + /// 태스크 완료 콜백 (taskId, result) + /// 취소 토큰 + public async Task ExecuteAsync( + CoordinatorPlan plan, + string workFolder, + Func> subAgentRunner, + Action? onTaskStarted = null, + Action? onTaskCompleted = null, + CancellationToken ct = default) + { + var results = new Dictionary(); // taskId → result + var remaining = plan.Tasks.ToList(); + + // 의존성 기반 토폴로지 정렬 실행 + while (remaining.Count > 0 && !ct.IsCancellationRequested) + { + // 현재 실행 가능한 태스크 찾기 (의존성이 모두 완료된 것) + var ready = remaining + .Where(t => t.Dependencies.All(dep => results.ContainsKey(dep))) + .ToList(); + + if (ready.Count == 0) + { + // 순환 의존성 또는 누락된 의존성 — 강제 진행 + ready = new List { remaining[0] }; + LogService.Warn($"[Coordinator] 의존성 해결 실패, 강제 실행: {remaining[0].Id}"); + } + + // 의존성 없는 태스크는 병렬 실행 + var parallelTasks = ready.Select(async task => + { + task.Status = SubTaskStatus.Running; + onTaskStarted?.Invoke(task.Id, task.Description); + + // 선행 태스크 결과를 컨텍스트로 구성 + var context = BuildContext(task, results, plan.OriginalRequest); + + try + { + var result = await subAgentRunner(task.AgentType, task.Description, context, ct); + task.Result = result; + task.Status = SubTaskStatus.Completed; + onTaskCompleted?.Invoke(task.Id, result); + return (task.Id, result, (Exception?)null); + } + catch (Exception ex) + { + task.Error = ex.Message; + task.Status = SubTaskStatus.Failed; + onTaskCompleted?.Invoke(task.Id, $"[오류] {ex.Message}"); + return (task.Id, $"[오류] {ex.Message}", ex); + } + }); + + var parallelResults = await Task.WhenAll(parallelTasks); + foreach (var (id, result, _) in parallelResults) + results[id] = result; + + // 완료된 태스크 제거 + foreach (var t in ready) + remaining.Remove(t); + } + + // 최종 결과 합성 + return await SynthesizeResultAsync(plan, results, ct); + } + + /// 모든 서브태스크 결과를 종합하여 최종 응답을 생성합니다. + private async Task SynthesizeResultAsync( + CoordinatorPlan plan, + Dictionary results, + CancellationToken ct) + { + if (results.Count == 0) return "실행된 태스크가 없습니다."; + + // 단일 태스크면 바로 반환 + if (plan.Tasks.Count == 1 && results.Count == 1) + return results.Values.First(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"원본 요청: {plan.OriginalRequest}"); + sb.AppendLine(); + foreach (var task in plan.Tasks) + { + if (results.TryGetValue(task.Id, out var result)) + { + sb.AppendLine($"[{task.AgentType}] {task.Description}:"); + sb.AppendLine(result.Length > 500 ? result[..500] + "…" : result); + sb.AppendLine(); + } + } + + var synthMessages = new List + { + new() { Role = "system", Content = "당신은 여러 서브에이전트 결과를 종합하는 코디네이터입니다. 결과를 간결하게 통합하여 최종 응답을 제공하세요." }, + new() { Role = "user", Content = sb.ToString() } + }; + + return await _llm.SendAsync(synthMessages, ct); + } + + // ─── 내부 헬퍼 ────────────────────────────────────────────────────────── + + private static string BuildContext( + CoordinatorSubTask task, + Dictionary previousResults, + string originalRequest) + { + var ctx = new System.Text.StringBuilder(); + ctx.AppendLine($"원본 요청: {originalRequest}"); + ctx.AppendLine($"현재 태스크: {task.Description}"); + + if (task.Dependencies.Count > 0) + { + ctx.AppendLine("\n선행 태스크 결과:"); + foreach (var dep in task.Dependencies) + { + if (previousResults.TryGetValue(dep, out var depResult)) + ctx.AppendLine($"- {dep}: {(depResult.Length > 300 ? depResult[..300] + "…" : depResult)}"); + } + } + + return ctx.ToString(); + } + + private static CoordinatorPlan ParsePlan(string originalRequest, string llmResponse) + { + try + { + // JSON 블록 추출 (```json ... ``` 또는 { ... }) + var json = ExtractJson(llmResponse); + if (string.IsNullOrEmpty(json)) + return FallbackPlan(originalRequest); + + var plan = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (plan == null || plan.Tasks.Count == 0) + return FallbackPlan(originalRequest); + + // 에이전트 유형 검증 + foreach (var task in plan.Tasks) + { + if (!KnownAgentTypes.Contains(task.AgentType, StringComparer.OrdinalIgnoreCase)) + task.AgentType = "implementer"; + } + + return plan; + } + catch (Exception ex) + { + LogService.Warn($"[Coordinator] 계획 파싱 실패: {ex.Message}"); + return FallbackPlan(originalRequest); + } + } + + private static string ExtractJson(string text) + { + // ```json ... ``` 블록 + var m = Regex.Match(text, @"```(?:json)?\s*([\s\S]*?)\s*```"); + if (m.Success) return m.Groups[1].Value.Trim(); + + // 첫 번째 { ... } 블록 + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start >= 0 && end > start) return text[start..(end + 1)]; + + return ""; + } + + private static CoordinatorPlan FallbackPlan(string request) => new() + { + OriginalRequest = request, + Tasks = new List + { + new() { Id = "task1", AgentType = "implementer", Description = request, Dependencies = new() } + } + }; +} diff --git a/src/AxCopilot/Views/ChatWindow.Sending.cs b/src/AxCopilot/Views/ChatWindow.Sending.cs index 408ab80..d6dfe22 100644 --- a/src/AxCopilot/Views/ChatWindow.Sending.cs +++ b/src/AxCopilot/Views/ChatWindow.Sending.cs @@ -264,6 +264,14 @@ public partial class ChatWindow // placeholder 정리 ClearPromptCardPlaceholder(); + // Phase 18: SlashCommandRegistry 명령 우선 실행 (/compact, /memory, /plan 등) + if (text.StartsWith("/")) + { + using var regCts = new CancellationTokenSource(); + if (await TryExecuteRegistrySlashCommandAsync(text, regCts.Token)) + return; // registry 명령이 처리됨 (내장 SlashCommands dict는 폴백) + } + // 슬래시 명령어 처리 var (slashSystem, displayText) = ParseSlashCommand(text); diff --git a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs index e1b0f62..65c5b9d 100644 --- a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs +++ b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs @@ -68,6 +68,14 @@ public partial class ChatWindow .Select(kv => (Cmd: kv.Key, Label: kv.Value.Label, IsSkill: false)) .ToList(); + // SlashCommandRegistry 명령 매칭 (/compact, /memory, /plan 등) + foreach (var rm in GetRegistrySlashMatches(text)) + { + // 내장 dict나 스킬에 이미 있는 것 제외 (중복 방지) + if (!matches.Any(m => m.Cmd.Equals(rm.Cmd, StringComparison.OrdinalIgnoreCase))) + matches.Add(rm); + } + // 스킬 슬래시 명령어 매칭 (탭별 필터) if (Llm.EnableSkillSystem) { diff --git a/src/AxCopilot/Views/ChatWindow.SlashContext.cs b/src/AxCopilot/Views/ChatWindow.SlashContext.cs new file mode 100644 index 0000000..cb94c3f --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.SlashContext.cs @@ -0,0 +1,184 @@ +using System.Windows; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; +using AxCopilot.Services.Agent.SlashCommands; + +namespace AxCopilot.Views; + +/// +/// Phase 18: SlashCommandRegistry IAgentChatContext 구현. +/// ChatWindow가 IAgentChatContext를 구현하여 SlashCommandRegistry 명령에 +/// LLM, 설정, UI 조작 기능을 제공합니다. +/// +public partial class ChatWindow : IAgentChatContext +{ + // ─── SlashCommandRegistry 연결 ────────────────────────────────────────── + + private readonly SlashCommandRegistry _slashRegistry = SlashCommandRegistry.CreateDefault(); + + // IAgentChatContext 구현 + + LlmService IAgentChatContext.Llm => _llm; + + SettingsService IAgentChatContext.Settings => _settings; + + IReadOnlyList IAgentChatContext.LoadedSkills => SkillService.Skills; + + IReadOnlyList IAgentChatContext.Hooks + { + get + { + var cfg = _settings.Settings.Llm.ExtendedHooks; + var allHooks = new List(); + + void AddHooks(IEnumerable? configs, HookEventKind kind) + { + if (configs == null) return; + foreach (var c in configs.Where(x => x.Enabled)) + allHooks.Add(new ExtendedHookEntry + { + Name = c.Name, + Event = kind, + Matcher = c.Matcher, + Mode = ParseHookMode(c.Mode), + ScriptPath = c.ScriptPath, + Url = c.Url, + Prompt = c.Prompt, + Model = c.Model, + Enabled = c.Enabled, + Once = c.Once, + IsAsync = c.IsAsync, + TimeoutSeconds = c.TimeoutSeconds > 0 ? c.TimeoutSeconds : 30, + StatusMessage = c.StatusMessage, + }); + } + + AddHooks(cfg.PreToolUse, HookEventKind.PreToolUse); + AddHooks(cfg.PostToolUse, HookEventKind.PostToolUse); + AddHooks(cfg.PostToolUseFailure, HookEventKind.PostToolUseFailure); + AddHooks(cfg.SessionStart, HookEventKind.SessionStart); + AddHooks(cfg.SessionEnd, HookEventKind.SessionEnd); + AddHooks(cfg.UserPromptSubmit, HookEventKind.UserPromptSubmit); + AddHooks(cfg.AgentStop, HookEventKind.AgentStop); + AddHooks(cfg.PreCompact, HookEventKind.PreCompact); + AddHooks(cfg.PostCompact, HookEventKind.PostCompact); + AddHooks(cfg.FileChanged, HookEventKind.FileChanged); + + return allHooks; + } + } + + string IAgentChatContext.ActiveTab => _activeTab; + + string IAgentChatContext.WorkFolder => GetCurrentWorkFolder(); + + SlashCommandRegistry IAgentChatContext.CommandRegistry => _slashRegistry; + + async Task IAgentChatContext.SendSystemMessageAsync(string message) + { + await Dispatcher.InvokeAsync(() => + { + AddMessageBubble("assistant", $"ℹ️ {message}"); + AutoScrollIfNeeded(); + }); + } + + async Task IAgentChatContext.PromptUserAsync(string question) + { + string? response = null; + await Dispatcher.InvokeAsync(() => + { + response = UserAskDialog.Show(question, new System.Collections.Generic.List()); + }); + return response; + } + + void IAgentChatContext.ClearMessages() + { + Dispatcher.Invoke(() => + { + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + lock (_convLock) + { + _currentConversation?.Messages.Clear(); + } + }); + } + + void IAgentChatContext.UpdateSessionHeader() + { + Dispatcher.Invoke(() => + { + UpdateModelLabel(); + UpdatePlanModeUI(); + }); + } + + void IAgentChatContext.ToggleSettingsPanel() => Dispatcher.Invoke(ToggleSettingsPanel); + + // ─── SlashCommandRegistry → ChatWindow 팝업 통합 ────────────────────── + + /// + /// SlashCommandRegistry 명령을 팝업 후보 목록에 추가합니다. + /// ChatWindow.SlashCommands.cs의 InputBox_TextChanged에서 호출됩니다. + /// + internal IEnumerable<(string Cmd, string Label, bool IsSkill)> GetRegistrySlashMatches(string prefix) + { + return _slashRegistry.GetAll() + .Where(c => ("/" + c.Name).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(c => ( + Cmd: "/" + c.Name, + Label: c.Description, + IsSkill: false + )); + } + + /// + /// 입력 텍스트가 SlashCommandRegistry 명령이면 실행합니다. + /// 실행 성공 시 true 반환. + /// + internal async Task TryExecuteRegistrySlashCommandAsync( + string fullInput, CancellationToken ct) + { + // "/compact 추가 지시사항" → name="compact", args="추가 지시사항" + var trimmed = fullInput.TrimStart('/'); + var spaceIdx = trimmed.IndexOf(' '); + var name = spaceIdx >= 0 ? trimmed[..spaceIdx] : trimmed; + var args = spaceIdx >= 0 ? trimmed[(spaceIdx + 1)..].Trim() : ""; + + var command = _slashRegistry.Resolve("/" + name); + if (command == null) return false; + + try + { + _isStreaming = true; + Dispatcher.Invoke(() => BtnSend.IsEnabled = false); + await command.ExecuteAsync(args, this, ct); + } + catch (OperationCanceledException) { /* 취소 */ } + catch (Exception ex) + { + await ((IAgentChatContext)this).SendSystemMessageAsync($"명령 실행 오류: {ex.Message}"); + } + finally + { + _isStreaming = false; + Dispatcher.Invoke(() => BtnSend.IsEnabled = true); + } + + return true; + } + + // ─── 내부 헬퍼 ────────────────────────────────────────────────────────── + + private static HookExecutionMode ParseHookMode(string? mode) => + mode?.ToLowerInvariant() switch + { + "http" => HookExecutionMode.Http, + "prompt" => HookExecutionMode.Prompt, + "agent" => HookExecutionMode.Agent, + _ => HookExecutionMode.Command, + }; +} diff --git a/src/AxCopilot/Views/SettingsWindow.Plugins.cs b/src/AxCopilot/Views/SettingsWindow.Plugins.cs new file mode 100644 index 0000000..8c5f38c --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.Plugins.cs @@ -0,0 +1,249 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services.Agent; +using AxCopilot.ViewModels; +using Microsoft.Win32; + +namespace AxCopilot.Views; + +/// +/// Phase 18-C1: SettingsWindow — 플러그인 갤러리 탭. +/// PluginGalleryViewModel을 코드비하인드에서 바인딩하여 플러그인 설치·관리를 지원합니다. +/// +public partial class SettingsWindow +{ + private PluginGalleryViewModel? _pluginVm; + + // ─── 초기화 ────────────────────────────────────────────────────────────── + + private void InitPluginGallery() + { + if (_pluginVm != null) return; + var installService = new PluginInstallService(); + _pluginVm = new PluginGalleryViewModel(installService); + _pluginVm.Refresh(); + RenderPluginList(); + } + + // ─── 버튼 이벤트 ───────────────────────────────────────────────────────── + + private async void BtnInstallPlugin_Click(object sender, MouseButtonEventArgs e) + { + var dlg = new OpenFileDialog + { + Title = "플러그인 zip 파일 선택", + Filter = "플러그인 패키지 (*.zip)|*.zip", + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + }; + + if (dlg.ShowDialog() != true) return; + + PluginStatusText.Text = "설치 중..."; + BtnInstallPlugin.IsEnabled = false; + + try + { + var svc = new PluginInstallService(); + var result = await svc.InstallFromZipAsync(dlg.FileName); + PluginStatusText.Text = result.Success + ? $"✓ {result.Message}" + : $"✗ {result.Message}"; + + if (result.Success) + { + _pluginVm?.Refresh(); + RenderPluginList(); + } + } + finally + { + BtnInstallPlugin.IsEnabled = true; + } + } + + private void BtnRefreshPlugins_Click(object sender, MouseButtonEventArgs e) + { + _pluginVm?.Refresh(); + RenderPluginList(); + PluginStatusText.Text = "새로고침 완료"; + } + + // ─── 플러그인 목록 렌더링 ────────────────────────────────────────────────── + + private void RenderPluginList() + { + if (_pluginVm == null || PluginListPanel == null) return; + PluginListPanel.Children.Clear(); + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)); + var accentColor = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + if (_pluginVm.Plugins.Count == 0) + { + PluginListPanel.Children.Add(new TextBlock + { + Text = "설치된 플러그인이 없습니다.\n'zip에서 설치' 버튼으로 플러그인을 추가하세요.", + FontSize = 12, + Foreground = secondaryText, + TextWrapping = System.Windows.TextWrapping.Wrap, + Margin = new Thickness(0, 8, 0, 8), + LineHeight = 20, + }); + return; + } + + foreach (var plugin in _pluginVm.Plugins) + { + var card = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(10), + Padding = new Thickness(16, 12, 16, 12), + Margin = new Thickness(0, 0, 0, 8), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 왼쪽: 플러그인 정보 + var infoStack = new StackPanel(); + + // 이름 + 유형 배지 + var nameRow = new StackPanel { Orientation = Orientation.Horizontal }; + nameRow.Children.Add(new TextBlock + { + Text = plugin.Name, + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + var typeBadge = new Border + { + Background = GetTypeBadgeColor(plugin.Type), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 2, 6, 2), + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + typeBadge.Child = new TextBlock + { + Text = plugin.Type, + FontSize = 10, + Foreground = Brushes.White, + FontWeight = FontWeights.SemiBold, + }; + nameRow.Children.Add(typeBadge); + infoStack.Children.Add(nameRow); + + infoStack.Children.Add(new TextBlock + { + Text = plugin.Description, + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 4, 0, 4), + TextWrapping = System.Windows.TextWrapping.Wrap, + }); + + infoStack.Children.Add(new TextBlock + { + Text = $"v{plugin.Version} | {plugin.Author} | 설치일: {plugin.InstalledAt}", + FontSize = 10, + Foreground = secondaryText, + }); + + Grid.SetColumn(infoStack, 0); + grid.Children.Add(infoStack); + + // 오른쪽: 활성화 토글 + 제거 + var actionStack = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + }; + + // 활성화 토글 + var isEnabled = plugin.IsEnabled; + var toggleBorder = new Border + { + Width = 40, Height = 22, CornerRadius = new CornerRadius(11), + Background = isEnabled ? accentColor : itemBg, + BorderBrush = secondaryText, BorderThickness = new Thickness(1), + Cursor = Cursors.Hand, + Margin = new Thickness(0, 0, 10, 0), + Tag = plugin, + }; + var toggleThumb = new Border + { + Width = 16, Height = 16, CornerRadius = new CornerRadius(8), + Background = Brushes.White, + HorizontalAlignment = isEnabled ? HorizontalAlignment.Right : HorizontalAlignment.Left, + Margin = new Thickness(3), + }; + toggleBorder.Child = toggleThumb; + var capturedPlugin = plugin; + toggleBorder.MouseLeftButtonUp += async (_, _) => + { + capturedPlugin.IsEnabled = !capturedPlugin.IsEnabled; + var svc = new PluginInstallService(); + await svc.SetEnabledAsync(capturedPlugin.Id, capturedPlugin.IsEnabled); + RenderPluginList(); // 재렌더링 + }; + actionStack.Children.Add(toggleBorder); + + // 제거 버튼 + var removeBorder = new Border + { + Width = 28, Height = 28, CornerRadius = new CornerRadius(6), + Background = Brushes.Transparent, Cursor = Cursors.Hand, + }; + removeBorder.Child = new TextBlock + { + Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 13, + Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + removeBorder.MouseEnter += (_, _) => removeBorder.Background = hoverBg; + removeBorder.MouseLeave += (_, _) => removeBorder.Background = Brushes.Transparent; + removeBorder.MouseLeftButtonUp += async (_, _) => + { + var confirm = CustomMessageBox.Show( + $"'{capturedPlugin.Name}' 플러그인을 제거하시겠습니까?", + "플러그인 제거", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (confirm != MessageBoxResult.Yes) return; + + var svc = new PluginInstallService(); + await svc.UninstallAsync(capturedPlugin.Id); + _pluginVm?.Refresh(); + RenderPluginList(); + PluginStatusText.Text = $"'{capturedPlugin.Name}' 제거 완료"; + }; + actionStack.Children.Add(removeBorder); + + Grid.SetColumn(actionStack, 1); + grid.Children.Add(actionStack); + + card.Child = grid; + PluginListPanel.Children.Add(card); + } + } + + private static Brush GetTypeBadgeColor(string type) => type?.ToLowerInvariant() switch + { + "skill" => new SolidColorBrush(Color.FromRgb(0x60, 0xA5, 0xFA)), + "tool" => new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)), + "theme" => new SolidColorBrush(Color.FromRgb(0xFB, 0xBF, 0x24)), + _ => new SolidColorBrush(Color.FromRgb(0x94, 0xA3, 0xB8)), + }; +} diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 7742b11..01e4064 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -5179,6 +5179,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs index 4880836..9956c25 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs @@ -77,6 +77,7 @@ public partial class SettingsWindow : Window BuildToolRegistryPanel(); LoadAdvancedSettings(); RefreshStorageInfo(); + InitPluginGallery(); // Phase 18-C1: 플러그인 갤러리 초기화 // 개발자 모드는 저장된 설정 유지 (끄면 하위 기능 모두 비활성) UpdateDevModeContentVisibility(); // AI 기능 토글 초기화