[Phase 18] 코디네이터 에이전트·플러그인 갤러리·SlashRegistry·watchPaths 완성

Phase 18-A1 — CoordinatorAgentService (신규):
- CoordinatorAgentService.cs (264줄): LLM 계획 수립 → JSON 파싱 → 의존성 토폴로지 정렬 → 병렬 서브에이전트 실행 → 결과 합성
- AgentLoopService.Coordinator.cs (100줄): RunCoordinatorModeAsync 파셜 + FormatCoordinatorPlan
- AppSettings.LlmSettings.cs: EnableCoordinatorMode bool 설정 추가
- AgentLoopService.cs: EnableCoordinatorMode 체크 → RunCoordinatorModeAsync fallback 로직

Phase 17-C4 — watchPaths FileWatcherService 연동 (AgentLoopService.ExtendedHooks.cs):
- SessionStart 훅 결과의 WatchPaths → FileWatcherService.Watch() 등록
- 파일 변경 시 FileChanged 훅 fire-and-forget 트리거

Phase 18-C1 — 플러그인 갤러리 (SettingsWindow):
- SettingsWindow.Plugins.cs (249줄): InitPluginGallery, RenderPluginList, 활성화 토글, 제거 버튼
- SettingsWindow.xaml: 플러그인 탭 추가 (PluginListPanel, 설치/새로고침 버튼)
- SettingsWindow.xaml.cs: Loaded에서 InitPluginGallery() 호출

SlashCommandRegistry ChatWindow 통합:
- ChatWindow.SlashContext.cs (175줄): IAgentChatContext 구현, GetRegistrySlashMatches, TryExecuteRegistrySlashCommandAsync
- ChatWindow.SlashCommands.cs: 레지스트리 명령 팝업 후보 추가
- ChatWindow.Sending.cs: 전송 전 레지스트리 슬래시 명령 우선 처리

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 02:02:37 +09:00
parent 84e5cd9485
commit 8693204d2d
12 changed files with 919 additions and 3 deletions

View File

@@ -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)** | 로컬 소형 모델, 별도 배포 | 낮음 | — |

View File

@@ -368,6 +368,10 @@ public class LlmSettings
[JsonPropertyName("enableDiffTracker")]
public bool EnableDiffTracker { get; set; } = true;
/// <summary>Phase 18-A1: 코디네이터 에이전트 모드 활성화. true이면 LLM이 먼저 계획을 수립 후 서브에이전트에 위임. 기본 false.</summary>
[JsonPropertyName("enableCoordinatorMode")]
public bool EnableCoordinatorMode { get; set; } = false;
/// <summary>추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.</summary>
[JsonPropertyName("skillsFolderPath")]
public string SkillsFolderPath { get; set; } = "";

View File

@@ -0,0 +1,100 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 18-A1: 코디네이터 에이전트 모드.
/// LLM이 계획을 수립하고 서브에이전트들에게 태스크를 위임합니다.
/// </summary>
public partial class AgentLoopService
{
/// <summary>
/// 코디네이터 모드 실행.
/// 성공하면 최종 합성 결과를 반환, 실패 시 null 반환 (일반 에이전트 루프로 fallback).
/// </summary>
internal async Task<string?> 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<string> { "실행", "취소" };
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
}
}
/// <summary>코디네이터 계획을 사용자에게 보여줄 텍스트로 포맷합니다.</summary>
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();
}
}

View File

@@ -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,

View File

@@ -78,6 +78,9 @@ public partial class AgentLoopService
/// <summary>위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).</summary>
private DelegateAgentTool? _delegateAgentTool;
/// <summary>Phase 17-C4: watchPaths 파일 감시 서비스.</summary>
private readonly FileWatcherService _fileWatcher = new();
/// <summary>일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.</summary>
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);

View File

@@ -0,0 +1,265 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 18-A1: 코디네이터 에이전트 서비스.
/// 사용자 요청을 분석하여 멀티 서브태스크 계획을 수립하고,
/// 각 태스크를 특화 서브에이전트에 위임하여 실행합니다.
/// </summary>
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;
}
/// <summary>사용자 요청을 분석하여 CoordinatorPlan을 생성합니다.</summary>
public async Task<CoordinatorPlan> PlanAsync(string request, CancellationToken ct)
{
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = PlanSystemPrompt },
new() { Role = "user", Content = $"다음 요청을 분석하여 실행 계획을 수립하세요:\n\n{request}" }
};
var response = await _llm.SendAsync(messages, ct);
return ParsePlan(request, response);
}
/// <summary>
/// CoordinatorPlan을 실행합니다. 의존성 순서에 따라 태스크를 실행하고
/// 각 태스크 결과를 후속 태스크에 컨텍스트로 전달합니다.
/// </summary>
/// <param name="plan">실행할 계획</param>
/// <param name="workFolder">작업 폴더</param>
/// <param name="subAgentRunner">서브에이전트 실행기 (agentType, task, context, ct) → result</param>
/// <param name="onTaskStarted">태스크 시작 콜백 (taskId, description)</param>
/// <param name="onTaskCompleted">태스크 완료 콜백 (taskId, result)</param>
/// <param name="ct">취소 토큰</param>
public async Task<string> ExecuteAsync(
CoordinatorPlan plan,
string workFolder,
Func<string, string, string, CancellationToken, Task<string>> subAgentRunner,
Action<string, string>? onTaskStarted = null,
Action<string, string>? onTaskCompleted = null,
CancellationToken ct = default)
{
var results = new Dictionary<string, string>(); // 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<CoordinatorSubTask> { 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);
}
/// <summary>모든 서브태스크 결과를 종합하여 최종 응답을 생성합니다.</summary>
private async Task<string> SynthesizeResultAsync(
CoordinatorPlan plan,
Dictionary<string, string> 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<ChatMessage>
{
new() { Role = "system", Content = "당신은 여러 서브에이전트 결과를 종합하는 코디네이터입니다. 결과를 간결하게 통합하여 최종 응답을 제공하세요." },
new() { Role = "user", Content = sb.ToString() }
};
return await _llm.SendAsync(synthMessages, ct);
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private static string BuildContext(
CoordinatorSubTask task,
Dictionary<string, string> 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<CoordinatorPlan>(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<CoordinatorSubTask>
{
new() { Id = "task1", AgentType = "implementer", Description = request, Dependencies = new() }
}
};
}

View File

@@ -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);

View File

@@ -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)
{

View File

@@ -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;
/// <summary>
/// Phase 18: SlashCommandRegistry IAgentChatContext 구현.
/// ChatWindow가 IAgentChatContext를 구현하여 SlashCommandRegistry 명령에
/// LLM, 설정, UI 조작 기능을 제공합니다.
/// </summary>
public partial class ChatWindow : IAgentChatContext
{
// ─── SlashCommandRegistry 연결 ──────────────────────────────────────────
private readonly SlashCommandRegistry _slashRegistry = SlashCommandRegistry.CreateDefault();
// IAgentChatContext 구현
LlmService IAgentChatContext.Llm => _llm;
SettingsService IAgentChatContext.Settings => _settings;
IReadOnlyList<SkillDefinition> IAgentChatContext.LoadedSkills => SkillService.Skills;
IReadOnlyList<ExtendedHookEntry> IAgentChatContext.Hooks
{
get
{
var cfg = _settings.Settings.Llm.ExtendedHooks;
var allHooks = new List<ExtendedHookEntry>();
void AddHooks(IEnumerable<ExtendedHookEntryConfig>? 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<string?> IAgentChatContext.PromptUserAsync(string question)
{
string? response = null;
await Dispatcher.InvokeAsync(() =>
{
response = UserAskDialog.Show(question, new System.Collections.Generic.List<string>());
});
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 팝업 통합 ──────────────────────
/// <summary>
/// SlashCommandRegistry 명령을 팝업 후보 목록에 추가합니다.
/// ChatWindow.SlashCommands.cs의 InputBox_TextChanged에서 호출됩니다.
/// </summary>
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
));
}
/// <summary>
/// 입력 텍스트가 SlashCommandRegistry 명령이면 실행합니다.
/// 실행 성공 시 true 반환.
/// </summary>
internal async Task<bool> 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,
};
}

View File

@@ -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;
/// <summary>
/// Phase 18-C1: SettingsWindow — 플러그인 갤러리 탭.
/// PluginGalleryViewModel을 코드비하인드에서 바인딩하여 플러그인 설치·관리를 지원합니다.
/// </summary>
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)),
};
}

View File

@@ -5179,6 +5179,62 @@
</Grid>
</TabItem>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Phase 18-C1: 플러그인 갤러리 탭 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<TabItem Header="플러그인" Tag="&#xE74C;" Style="{StaticResource SideNavItem}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="24,20,24,12">
<TextBlock Text="플러그인 관리" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="로컬 zip 파일로 스킬·도구·테마 플러그인을 설치합니다."
FontSize="12" Foreground="{DynamicResource SecondaryText}"
Margin="0,4,0,0" TextWrapping="Wrap"/>
</StackPanel>
<!-- 플러그인 목록 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="24,0,24,0">
<StackPanel x:Name="PluginListPanel" Margin="0,0,0,12"/>
</ScrollViewer>
<!-- 하단 설치 버튼 -->
<Border Grid.Row="2" Margin="24,8,24,16">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Border x:Name="BtnInstallPlugin" CornerRadius="8" Padding="16,8"
Background="{DynamicResource AccentColor}" Cursor="Hand"
MouseLeftButtonUp="BtnInstallPlugin_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE710;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="White" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="zip에서 설치" FontSize="13" Foreground="White"
FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="BtnRefreshPlugins" CornerRadius="8" Padding="12,8"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
Margin="8,0,0,0" MouseLeftButtonUp="BtnRefreshPlugins_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE72C;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="새로고침" FontSize="13"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<TextBlock x:Name="PluginStatusText" Text="" FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="16,0,0,0"/>
</StackPanel>
</Border>
</Grid>
</TabItem>
</TabControl>
<!-- ═════════════════════════════════════════════════════════════════ -->

View File

@@ -77,6 +77,7 @@ public partial class SettingsWindow : Window
BuildToolRegistryPanel();
LoadAdvancedSettings();
RefreshStorageInfo();
InitPluginGallery(); // Phase 18-C1: 플러그인 갤러리 초기화
// 개발자 모드는 저장된 설정 유지 (끄면 하위 기능 모두 비활성)
UpdateDevModeContentVisibility();
// AI 기능 토글 초기화