[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:
@@ -231,7 +231,7 @@
|
|||||||
| 17-C1 | **훅 이벤트 확장** | UserPromptSubmit, PreCompact/PostCompact, FileChanged, CwdChanged, SessionEnd, ConfigChange 추가 | 최고 |
|
| 17-C1 | **훅 이벤트 확장** | UserPromptSubmit, PreCompact/PostCompact, FileChanged, CwdChanged, SessionEnd, ConfigChange 추가 | 최고 |
|
||||||
| 17-C2 | **훅 타입 확장** | type:prompt (LLM 보안검사), type:agent (에이전트 루프 검증) 추가 | 최고 |
|
| 17-C2 | **훅 타입 확장** | type:prompt (LLM 보안검사), type:agent (에이전트 루프 검증) 추가 | 최고 |
|
||||||
| 17-C3 | **훅 속성·출력 고도화** | if/once/async/statusMessage 속성. additionalContext·permissionDecision·updatedInput 출력 | 높음 |
|
| 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 스킬 문서 기반)
|
### 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-A2 ✅ | **Worktree 격리 서브에이전트** | WorktreeManager 구현 완료 + DelegateAgentTool에 주입 (isolation:"worktree" 파라미터 지원) | 최고 | — |
|
||||||
| 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 |
|
| 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 |
|
||||||
| 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — |
|
| 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — |
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
| # | 기능 | 설명 | 우선순위 | 갭 |
|
| # | 기능 | 설명 | 우선순위 | 갭 |
|
||||||
|---|------|------|----------|----|
|
|---|------|------|----------|----|
|
||||||
| 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 |
|
| 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 |
|
||||||
| 18-C1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 기반 인앱 갤러리. zip 설치 | 높음 | — |
|
| 18-C1 ✅ | **플러그인 갤러리 + 레지스트리** | PluginGalleryViewModel + PluginInstallService + SettingsWindow 플러그인 탭 구현 완료. zip 설치·활성화 토글·제거 UI 완비 | 높음 | — |
|
||||||
| 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — |
|
| 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — |
|
||||||
| 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — |
|
| 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — |
|
||||||
| 18-C4 | **오프라인 AI (ONNX Runtime)** | 로컬 소형 모델, 별도 배포 | 낮음 | — |
|
| 18-C4 | **오프라인 AI (ONNX Runtime)** | 로컬 소형 모델, 별도 배포 | 낮음 | — |
|
||||||
|
|||||||
@@ -368,6 +368,10 @@ public class LlmSettings
|
|||||||
[JsonPropertyName("enableDiffTracker")]
|
[JsonPropertyName("enableDiffTracker")]
|
||||||
public bool EnableDiffTracker { get; set; } = true;
|
public bool EnableDiffTracker { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Phase 18-A1: 코디네이터 에이전트 모드 활성화. true이면 LLM이 먼저 계획을 수립 후 서브에이전트에 위임. 기본 false.</summary>
|
||||||
|
[JsonPropertyName("enableCoordinatorMode")]
|
||||||
|
public bool EnableCoordinatorMode { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.</summary>
|
/// <summary>추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.</summary>
|
||||||
[JsonPropertyName("skillsFolderPath")]
|
[JsonPropertyName("skillsFolderPath")]
|
||||||
public string SkillsFolderPath { get; set; } = "";
|
public string SkillsFolderPath { get; set; } = "";
|
||||||
|
|||||||
100
src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs
Normal file
100
src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,32 @@ public partial class AgentLoopService
|
|||||||
var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm);
|
var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm);
|
||||||
ApplyExtendedHookResult(result, messages);
|
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 이벤트 로그 기록
|
// HookFired 이벤트 로그 기록
|
||||||
if (_eventLog != null && hooks.Count > 0)
|
if (_eventLog != null && hooks.Count > 0)
|
||||||
_ = _eventLog.AppendAsync(AgentEventLogType.HookFired,
|
_ = _eventLog.AppendAsync(AgentEventLogType.HookFired,
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ public partial class AgentLoopService
|
|||||||
/// <summary>위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).</summary>
|
/// <summary>위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).</summary>
|
||||||
private DelegateAgentTool? _delegateAgentTool;
|
private DelegateAgentTool? _delegateAgentTool;
|
||||||
|
|
||||||
|
/// <summary>Phase 17-C4: watchPaths 파일 감시 서비스.</summary>
|
||||||
|
private readonly FileWatcherService _fileWatcher = new();
|
||||||
|
|
||||||
/// <summary>일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.</summary>
|
/// <summary>일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.</summary>
|
||||||
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
|
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
|
||||||
|
|
||||||
@@ -224,6 +227,18 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
var context = BuildContext(activeTabSnapshot);
|
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 — 과거 교훈을 시스템 메시지에 주입
|
// Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입
|
||||||
await InjectReflexionContextAsync(messages, userQuery);
|
await InjectReflexionContextAsync(messages, userQuery);
|
||||||
|
|
||||||
|
|||||||
265
src/AxCopilot/Services/Agent/CoordinatorAgentService.cs
Normal file
265
src/AxCopilot/Services/Agent/CoordinatorAgentService.cs
Normal 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() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -264,6 +264,14 @@ public partial class ChatWindow
|
|||||||
// placeholder 정리
|
// placeholder 정리
|
||||||
ClearPromptCardPlaceholder();
|
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);
|
var (slashSystem, displayText) = ParseSlashCommand(text);
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ public partial class ChatWindow
|
|||||||
.Select(kv => (Cmd: kv.Key, Label: kv.Value.Label, IsSkill: false))
|
.Select(kv => (Cmd: kv.Key, Label: kv.Value.Label, IsSkill: false))
|
||||||
.ToList();
|
.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)
|
if (Llm.EnableSkillSystem)
|
||||||
{
|
{
|
||||||
|
|||||||
184
src/AxCopilot/Views/ChatWindow.SlashContext.cs
Normal file
184
src/AxCopilot/Views/ChatWindow.SlashContext.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
249
src/AxCopilot/Views/SettingsWindow.Plugins.cs
Normal file
249
src/AxCopilot/Views/SettingsWindow.Plugins.cs
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5179,6 +5179,62 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Phase 18-C1: 플러그인 갤러리 탭 -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<TabItem Header="플러그인" Tag="" 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="" 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="" 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>
|
</TabControl>
|
||||||
|
|
||||||
<!-- ═════════════════════════════════════════════════════════════════ -->
|
<!-- ═════════════════════════════════════════════════════════════════ -->
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ public partial class SettingsWindow : Window
|
|||||||
BuildToolRegistryPanel();
|
BuildToolRegistryPanel();
|
||||||
LoadAdvancedSettings();
|
LoadAdvancedSettings();
|
||||||
RefreshStorageInfo();
|
RefreshStorageInfo();
|
||||||
|
InitPluginGallery(); // Phase 18-C1: 플러그인 갤러리 초기화
|
||||||
// 개발자 모드는 저장된 설정 유지 (끄면 하위 기능 모두 비활성)
|
// 개발자 모드는 저장된 설정 유지 (끄면 하위 기능 모두 비활성)
|
||||||
UpdateDevModeContentVisibility();
|
UpdateDevModeContentVisibility();
|
||||||
// AI 기능 토글 초기화
|
// AI 기능 토글 초기화
|
||||||
|
|||||||
Reference in New Issue
Block a user