[Phase 41] SettingsViewModel·AgentLoopService 파셜 클래스 분할

SettingsViewModel (1,855줄 → 320줄, 82.7% 감소):
- SettingsViewModel.Properties.cs (837줄): 바인딩 프로퍼티 전체
- SettingsViewModel.Methods.cs (469줄): Save/Browse/Add 등 메서드
- SettingsViewModelModels.cs (265줄): 6개 모델 클래스 분리

AgentLoopService (1,823줄 → 1,334줄, 26.8% 감소):
- AgentLoopService.Execution.cs (498줄): 병렬 도구 실행, ToolExecutionState
- 빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 19:37:24 +09:00
parent 6448451d78
commit c9a6e6442f
7 changed files with 2099 additions and 2027 deletions

View File

@@ -4605,5 +4605,32 @@ ThemeResourceHelper에 5개 정적 필드 추가:
---
최종 업데이트: 2026-04-03 (Phase 22~40 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 8차)
## Phase 41 — SettingsViewModel·AgentLoopService 파셜 분할 (v2.3) ✅ 완료
> **목표**: SettingsViewModel (1,855줄)·AgentLoopService (1,823줄) 파셜 클래스 분할.
### SettingsViewModel 분할
| 파일 | 줄 수 | 내용 |
|------|-------|------|
| `SettingsViewModel.cs` (메인) | 320 | 클래스 선언, 필드, 이벤트, 생성자 |
| `SettingsViewModel.Properties.cs` | 837 | 바인딩 프로퍼티 전체 (LLM, 런처, 기능토글, 테마 등) |
| `SettingsViewModel.Methods.cs` | 469 | Save, Browse, AddShortcut, AddSnippet 등 메서드 |
| `SettingsViewModelModels.cs` | 265 | ThemeCardModel, ColorRowModel, SnippetRowModel 등 6개 모델 클래스 |
- **메인 파일**: 1,855줄 → 320줄 (**82.7% 감소**)
### AgentLoopService 분할
| 파일 | 줄 수 | 내용 |
|------|-------|------|
| `AgentLoopService.cs` (메인) | 1,334 | 상수, 필드, 생성자, RunAsync 루프, 헬퍼 |
| `AgentLoopService.Execution.cs` | 498 | TruncateOutput, ReadOnlyTools, ClassifyToolCalls, ToolExecutionState, ParallelState, ExecuteToolsInParallelAsync |
- **메인 파일**: 1,823줄 → 1,334줄 (**26.8% 감소**)
- **빌드**: 경고 0, 오류 0
---
최종 업데이트: 2026-04-03 (Phase 22~41 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 9차)

View File

@@ -0,0 +1,498 @@
using System.Diagnostics;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static string TruncateOutput(string output, int maxLength)
{
if (output.Length <= maxLength) return output;
return output[..maxLength] + "\n... (출력 잘림)";
}
// ─── 병렬 도구 실행 ──────────────────────────────────────────────────
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "skill_manager", "json_tool",
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
{
var parallel = new List<LlmService.ContentBlock>();
var sequential = new List<LlmService.ContentBlock>();
foreach (var call in calls)
{
if (ReadOnlyTools.Contains(call.ToolName ?? ""))
parallel.Add(call);
else
sequential.Add(call);
}
// 읽기 전용 도구가 1개뿐이면 병렬화 의미 없음
if (parallel.Count <= 1)
{
sequential.InsertRange(0, parallel);
parallel.Clear();
}
return (parallel, sequential);
}
// ─── Phase 33-B: 도구 실행 루프 상태 (RunAsync에서 추출) ─────────────
/// <summary>도구 실행 루프의 가변 상태. 메서드 간 공유.</summary>
private class ToolExecutionState
{
public int CurrentStep;
public int TotalToolCalls;
public int MaxIterations;
public int BaseMax;
public int MaxRetry;
public int ConsecutiveErrors;
public int StatsSuccessCount;
public int StatsFailCount;
public int StatsInputTokens;
public int StatsOutputTokens;
public List<string> StatsUsedTools = new();
public List<string> PlanSteps = new();
public bool DocumentPlanCalled;
public string? DocumentPlanPath;
public string? DocumentPlanTitle;
public string? DocumentPlanScaffold;
}
/// <summary>병렬 실행용 가변 상태 (ToolExecutionState의 서브셋). TODO: ToolExecutionState로 통합 예정.</summary>
private class ParallelState
{
public int CurrentStep;
public int TotalToolCalls;
public int MaxIterations;
public int ConsecutiveErrors;
public int StatsSuccessCount;
public int StatsFailCount;
public int StatsInputTokens;
public int StatsOutputTokens;
}
/// <summary>
/// Phase 33-B: 단일 도구 호출을 처리합니다 (RunAsync에서 추출).
/// 훅 실행 → 도구 실행 → 결과 처리 → 검증까지 단일 도구 라이프사이클.
/// </summary>
/// <returns>LoopAction: Continue=다음 도구, Break=루프 중단, Return=값 반환.</returns>
private async Task<(ToolCallAction Action, string? ReturnValue)> ProcessSingleToolCallAsync(
LlmService.ContentBlock call,
List<ChatMessage> messages,
AgentContext context,
ToolExecutionState state,
List<LlmService.ContentBlock> toolCalls,
string activeTabSnapshot,
int iteration,
CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var tool = _tools.Get(call.ToolName);
if (tool == null)
{
var errResult = $"알 수 없는 도구: {call.ToolName}";
EmitEvent(AgentEventType.Error, call.ToolName, errResult);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult));
return (ToolCallAction.Continue, null);
}
// Task Decomposition: 단계 진행률 추적
if (state.PlanSteps.Count > 0)
{
var summary = FormatToolCallSummary(call);
var newStep = TaskDecomposer.EstimateCurrentStep(
state.PlanSteps, call.ToolName, summary, state.CurrentStep);
if (newStep != state.CurrentStep)
{
state.CurrentStep = newStep;
EmitEvent(AgentEventType.StepStart, "", state.PlanSteps[state.CurrentStep],
stepCurrent: state.CurrentStep + 1, stepTotal: state.PlanSteps.Count);
}
}
// 개발자 모드: 도구 호출 파라미터 상세 표시
if (context.DevMode)
{
var paramJson = call.ToolInput?.ToString() ?? "{}";
if (paramJson.Length > 500) paramJson = paramJson[..500] + "...";
EmitEvent(AgentEventType.Thinking, call.ToolName,
$"[DEV] 도구 호출: {call.ToolName}\n파라미터: {paramJson}");
}
// 이벤트 로그 기록
if (_eventLog != null)
_ = _eventLog.AppendAsync(AgentEventLogType.ToolRequest,
JsonSerializer.Serialize(new { toolName = call.ToolName, iteration }));
EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call));
// 개발자 모드: 스텝 바이 스텝 승인
if (context.DevModeStepApproval && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
$"[DEV] 도구 '{call.ToolName}' 실행을 승인하시겠습니까?\n{FormatToolCallSummary(call)}",
new List<string> { "승인", "건너뛰기", "중단" });
if (decision == "중단")
{
EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다");
return (ToolCallAction.Return, "사용자가 개발자 모드에서 실행을 중단했습니다.");
}
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
// 영향 범위 기반 의사결정 체크
var decisionRequired = CheckDecisionRequired(call, context);
if (decisionRequired != null && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
decisionRequired,
new List<string> { "승인", "건너뛰기", "취소" });
if (decision == "취소")
{
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
return (ToolCallAction.Return, "사용자가 작업을 취소했습니다.");
}
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
// ── Pre-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "pre", ct);
// ── 도구 실행 ──
ToolResult result;
var sw = Stopwatch.StartNew();
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
result = await tool.ExecuteAsync(input, context, ct);
}
catch (OperationCanceledException)
{
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.");
return (ToolCallAction.Return, "사용자가 작업을 취소했습니다.");
}
catch (Exception ex)
{
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
}
sw.Stop();
// ── Post-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "post", ct, result);
// 개발자 모드: 도구 결과 상세 표시
if (context.DevMode)
{
EmitEvent(AgentEventType.Thinking, call.ToolName,
$"[DEV] 결과: {(result.Success ? "" : "")}\n{TruncateOutput(result.Output, 500)}");
}
var tokenUsage = _llm.LastTokenUsage;
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: sw.ElapsedMilliseconds,
inputTokens: tokenUsage?.PromptTokens ?? 0,
outputTokens: tokenUsage?.CompletionTokens ?? 0,
toolInput: call.ToolInput?.ToString(),
iteration: iteration);
// 통계 수집
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
state.StatsInputTokens += tokenUsage?.PromptTokens ?? 0;
state.StatsOutputTokens += tokenUsage?.CompletionTokens ?? 0;
if (!state.StatsUsedTools.Contains(call.ToolName))
state.StatsUsedTools.Add(call.ToolName);
// 감사 로그 기록
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, activeTabSnapshot,
call.ToolName,
call.ToolInput.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
// 이벤트 로그
if (_eventLog != null)
_ = _eventLog.AppendAsync(AgentEventLogType.ToolResult,
JsonSerializer.Serialize(new
{
toolName = call.ToolName,
success = result.Success,
outputLength = result.Output?.Length ?? 0
}));
state.TotalToolCalls++;
// 동적 반복 한도 확장
if (state.TotalToolCalls > Defaults.ToolCallThresholdForExpansion && state.MaxIterations < state.BaseMax * 2)
state.MaxIterations = Math.Min(state.BaseMax * 2, 50);
// 테스트-수정 루프 예산 확장
if (call.ToolName == "test_loop" && (result.Output?.Contains("[AUTO_FIX:") ?? false))
{
var testFixMax = llm.MaxTestFixIterations > 0 ? llm.MaxTestFixIterations : Defaults.MaxTestFixIterations;
var testFixBudget = state.BaseMax + testFixMax * 3;
if (state.MaxIterations < testFixBudget)
state.MaxIterations = Math.Min(testFixBudget, 60);
}
// UI 스레드가 이벤트를 렌더링할 시간 확보
await Task.Delay(80, ct);
// Self-Reflection: 도구 실패 시 반성 프롬프트 추가
if (!result.Success)
{
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors <= state.MaxRetry)
{
var reflectionMsg = $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
$"Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " +
$"Try a different approach. (Error {state.ConsecutiveErrors}/{state.MaxRetry})";
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, reflectionMsg));
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({state.ConsecutiveErrors}/{state.MaxRetry})");
}
else
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
$"[FAILED after {state.MaxRetry} retries] {TruncateOutput(result.Output ?? "", 500)}\n" +
"Stop retrying this tool. Explain the error to the user and suggest alternative approaches."));
// 에이전트 메모리에 실패 원인 저장
try
{
var memSvc = CurrentApp?.MemoryService;
if (memSvc != null && (_settings.Settings.Llm.EnableAgentMemory))
{
memSvc.Add("correction",
$"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output ?? "", Defaults.LogSummaryMaxLength)}",
$"conv:{_conversationId}", context.WorkFolder);
}
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] 에이전트 메모리 저장 실패: {ex.Message}"); }
}
}
else
{
state.ConsecutiveErrors = 0;
// ToolResultSizer 적용
var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output));
// document_plan 호출 플래그 + 메타데이터 저장
if (call.ToolName == "document_plan" && result.Success)
{
state.DocumentPlanCalled = true;
try
{
var planJson = JsonDocument.Parse(result.Output ?? "{}");
if (planJson.RootElement.TryGetProperty("path", out var p))
state.DocumentPlanPath = p.GetString();
if (planJson.RootElement.TryGetProperty("title", out var t))
state.DocumentPlanTitle = t.GetString();
if (planJson.RootElement.TryGetProperty("body", out var b))
state.DocumentPlanScaffold = b.GetString();
}
catch (Exception) { /* document_plan JSON 파싱 실패 — 선택적 메타데이터이므로 무시 */ }
// html_create 즉시 호출 유도
var toolHint = (result.Output?.Contains("document_assemble") ?? false) ? "document_assemble" :
(result.Output?.Contains("file_write") ?? false) ? "file_write" : "html_create";
messages.Add(new ChatMessage { Role = "user",
Content = $"document_plan이 완료되었습니다. " +
$"위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 " +
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
$"각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요." });
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완성 — {toolHint} 호출 중...");
}
// 단일 문서 생성 도구 성공 → 즉시 루프 종료
if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1)
{
var shouldVerify2 = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
if (shouldVerify2)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (ToolCallAction.Return, result.Output ?? "");
}
// Post-Tool Verification: 탭별 검증 강제
var shouldVerify = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
if (shouldVerify && result.Success)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
}
}
return (ToolCallAction.Continue, null);
}
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
private enum ToolCallAction { Continue, Break, Return }
/// <summary>Phase 33-B: 도구 훅(Pre/Post) 실행 헬퍼.</summary>
private async Task RunToolHooksAsync(
Models.LlmSettings llm, LlmService.ContentBlock call,
AgentContext context, string phase, CancellationToken ct,
ToolResult? result = null)
{
if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return;
try
{
var hookResults = await AgentHookRunner.RunAsync(
llm.AgentHooks, call.ToolName, phase,
toolInput: call.ToolInput.ToString(),
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
success: result?.Success ?? false,
workFolder: context.WorkFolder,
timeoutMs: llm.ToolHookTimeoutMs,
ct: ct);
foreach (var pr in hookResults.Where(r => !r.Success))
EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}");
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 훅 실패: {ex.Message}"); }
}
/// <summary>DelegateAgentTool에서 호출하는 서브에이전트 실행기.</summary>
private async Task<string> RunSubAgentAsync(string agentType, string task, string workFolder, CancellationToken ct)
{
try
{
var systemPrompt = $"당신은 전문 {agentType} 에이전트입니다. 주어진 작업을 수행하세요.";
var msgs = new List<ChatMessage>
{
new() { Role = "system", Content = systemPrompt },
new() { Role = "user", Content = task }
};
// 서브에이전트는 간단한 텍스트 응답으로 처리 (도구 호출 없이)
var response = await _llm.SendAsync(msgs, ct);
return response;
}
catch (Exception ex)
{
return $"서브에이전트 실행 오류: {ex.Message}";
}
}
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls,
List<ChatMessage> messages,
AgentContext context,
List<string> planSteps,
ParallelState state,
int baseMax, int maxRetry,
Models.LlmSettings llm,
int iteration,
CancellationToken ct,
List<string> statsUsedTools)
{
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
var tasks = calls.Select(async call =>
{
var tool = _tools.Get(call.ToolName);
if (tool == null)
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await tool.ExecuteAsync(input, context, ct);
sw.Stop();
return (call, result, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
}
}).ToList();
var results = await Task.WhenAll(tasks);
// 결과를 순서대로 메시지에 추가
foreach (var (call, result, elapsed) in results)
{
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: elapsed,
iteration: iteration);
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
if (!statsUsedTools.Contains(call.ToolName))
statsUsedTools.Add(call.ToolName);
state.TotalToolCalls++;
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, TruncateOutput(result.Output, Defaults.ToolResultTruncateLength)));
if (!result.Success)
{
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors > maxRetry)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
$"[FAILED after retries] {TruncateOutput(result.Output, 500)}"));
}
}
else
{
state.ConsecutiveErrors = 0;
}
// 감사 로그
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, context.ActiveTab ?? "",
call.ToolName,
call.ToolInput?.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
}
}
}

View File

@@ -10,7 +10,7 @@ namespace AxCopilot.Services.Agent;
/// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프.
/// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다.
/// </summary>
public class AgentLoopService
public partial class AgentLoopService
{
private static App? CurrentApp => System.Windows.Application.Current as App;
@@ -1331,493 +1331,4 @@ public class AgentLoopService
}
catch (Exception) { return call.ToolName; }
}
private static string TruncateOutput(string output, int maxLength)
{
if (output.Length <= maxLength) return output;
return output[..maxLength] + "\n... (출력 잘림)";
}
// ─── 병렬 도구 실행 ──────────────────────────────────────────────────
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "skill_manager", "json_tool",
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
{
var parallel = new List<LlmService.ContentBlock>();
var sequential = new List<LlmService.ContentBlock>();
foreach (var call in calls)
{
if (ReadOnlyTools.Contains(call.ToolName ?? ""))
parallel.Add(call);
else
sequential.Add(call);
}
// 읽기 전용 도구가 1개뿐이면 병렬화 의미 없음
if (parallel.Count <= 1)
{
sequential.InsertRange(0, parallel);
parallel.Clear();
}
return (parallel, sequential);
}
// ─── Phase 33-B: 도구 실행 루프 상태 (RunAsync에서 추출) ─────────────
/// <summary>도구 실행 루프의 가변 상태. 메서드 간 공유.</summary>
private class ToolExecutionState
{
public int CurrentStep;
public int TotalToolCalls;
public int MaxIterations;
public int BaseMax;
public int MaxRetry;
public int ConsecutiveErrors;
public int StatsSuccessCount;
public int StatsFailCount;
public int StatsInputTokens;
public int StatsOutputTokens;
public List<string> StatsUsedTools = new();
public List<string> PlanSteps = new();
public bool DocumentPlanCalled;
public string? DocumentPlanPath;
public string? DocumentPlanTitle;
public string? DocumentPlanScaffold;
}
/// <summary>병렬 실행용 가변 상태 (ToolExecutionState의 서브셋). TODO: ToolExecutionState로 통합 예정.</summary>
private class ParallelState
{
public int CurrentStep;
public int TotalToolCalls;
public int MaxIterations;
public int ConsecutiveErrors;
public int StatsSuccessCount;
public int StatsFailCount;
public int StatsInputTokens;
public int StatsOutputTokens;
}
/// <summary>
/// Phase 33-B: 단일 도구 호출을 처리합니다 (RunAsync에서 추출).
/// 훅 실행 → 도구 실행 → 결과 처리 → 검증까지 단일 도구 라이프사이클.
/// </summary>
/// <returns>LoopAction: Continue=다음 도구, Break=루프 중단, Return=값 반환.</returns>
private async Task<(ToolCallAction Action, string? ReturnValue)> ProcessSingleToolCallAsync(
LlmService.ContentBlock call,
List<ChatMessage> messages,
AgentContext context,
ToolExecutionState state,
List<LlmService.ContentBlock> toolCalls,
string activeTabSnapshot,
int iteration,
CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var tool = _tools.Get(call.ToolName);
if (tool == null)
{
var errResult = $"알 수 없는 도구: {call.ToolName}";
EmitEvent(AgentEventType.Error, call.ToolName, errResult);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult));
return (ToolCallAction.Continue, null);
}
// Task Decomposition: 단계 진행률 추적
if (state.PlanSteps.Count > 0)
{
var summary = FormatToolCallSummary(call);
var newStep = TaskDecomposer.EstimateCurrentStep(
state.PlanSteps, call.ToolName, summary, state.CurrentStep);
if (newStep != state.CurrentStep)
{
state.CurrentStep = newStep;
EmitEvent(AgentEventType.StepStart, "", state.PlanSteps[state.CurrentStep],
stepCurrent: state.CurrentStep + 1, stepTotal: state.PlanSteps.Count);
}
}
// 개발자 모드: 도구 호출 파라미터 상세 표시
if (context.DevMode)
{
var paramJson = call.ToolInput?.ToString() ?? "{}";
if (paramJson.Length > 500) paramJson = paramJson[..500] + "...";
EmitEvent(AgentEventType.Thinking, call.ToolName,
$"[DEV] 도구 호출: {call.ToolName}\n파라미터: {paramJson}");
}
// 이벤트 로그 기록
if (_eventLog != null)
_ = _eventLog.AppendAsync(AgentEventLogType.ToolRequest,
JsonSerializer.Serialize(new { toolName = call.ToolName, iteration }));
EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call));
// 개발자 모드: 스텝 바이 스텝 승인
if (context.DevModeStepApproval && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
$"[DEV] 도구 '{call.ToolName}' 실행을 승인하시겠습니까?\n{FormatToolCallSummary(call)}",
new List<string> { "승인", "건너뛰기", "중단" });
if (decision == "중단")
{
EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다");
return (ToolCallAction.Return, "사용자가 개발자 모드에서 실행을 중단했습니다.");
}
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
// 영향 범위 기반 의사결정 체크
var decisionRequired = CheckDecisionRequired(call, context);
if (decisionRequired != null && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
decisionRequired,
new List<string> { "승인", "건너뛰기", "취소" });
if (decision == "취소")
{
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
return (ToolCallAction.Return, "사용자가 작업을 취소했습니다.");
}
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
// ── Pre-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "pre", ct);
// ── 도구 실행 ──
ToolResult result;
var sw = Stopwatch.StartNew();
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
result = await tool.ExecuteAsync(input, context, ct);
}
catch (OperationCanceledException)
{
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.");
return (ToolCallAction.Return, "사용자가 작업을 취소했습니다.");
}
catch (Exception ex)
{
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
}
sw.Stop();
// ── Post-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "post", ct, result);
// 개발자 모드: 도구 결과 상세 표시
if (context.DevMode)
{
EmitEvent(AgentEventType.Thinking, call.ToolName,
$"[DEV] 결과: {(result.Success ? "" : "")}\n{TruncateOutput(result.Output, 500)}");
}
var tokenUsage = _llm.LastTokenUsage;
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: sw.ElapsedMilliseconds,
inputTokens: tokenUsage?.PromptTokens ?? 0,
outputTokens: tokenUsage?.CompletionTokens ?? 0,
toolInput: call.ToolInput?.ToString(),
iteration: iteration);
// 통계 수집
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
state.StatsInputTokens += tokenUsage?.PromptTokens ?? 0;
state.StatsOutputTokens += tokenUsage?.CompletionTokens ?? 0;
if (!state.StatsUsedTools.Contains(call.ToolName))
state.StatsUsedTools.Add(call.ToolName);
// 감사 로그 기록
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, activeTabSnapshot,
call.ToolName,
call.ToolInput.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
// 이벤트 로그
if (_eventLog != null)
_ = _eventLog.AppendAsync(AgentEventLogType.ToolResult,
JsonSerializer.Serialize(new
{
toolName = call.ToolName,
success = result.Success,
outputLength = result.Output?.Length ?? 0
}));
state.TotalToolCalls++;
// 동적 반복 한도 확장
if (state.TotalToolCalls > Defaults.ToolCallThresholdForExpansion && state.MaxIterations < state.BaseMax * 2)
state.MaxIterations = Math.Min(state.BaseMax * 2, 50);
// 테스트-수정 루프 예산 확장
if (call.ToolName == "test_loop" && (result.Output?.Contains("[AUTO_FIX:") ?? false))
{
var testFixMax = llm.MaxTestFixIterations > 0 ? llm.MaxTestFixIterations : Defaults.MaxTestFixIterations;
var testFixBudget = state.BaseMax + testFixMax * 3;
if (state.MaxIterations < testFixBudget)
state.MaxIterations = Math.Min(testFixBudget, 60);
}
// UI 스레드가 이벤트를 렌더링할 시간 확보
await Task.Delay(80, ct);
// Self-Reflection: 도구 실패 시 반성 프롬프트 추가
if (!result.Success)
{
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors <= state.MaxRetry)
{
var reflectionMsg = $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
$"Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " +
$"Try a different approach. (Error {state.ConsecutiveErrors}/{state.MaxRetry})";
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, reflectionMsg));
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({state.ConsecutiveErrors}/{state.MaxRetry})");
}
else
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
$"[FAILED after {state.MaxRetry} retries] {TruncateOutput(result.Output ?? "", 500)}\n" +
"Stop retrying this tool. Explain the error to the user and suggest alternative approaches."));
// 에이전트 메모리에 실패 원인 저장
try
{
var memSvc = CurrentApp?.MemoryService;
if (memSvc != null && (_settings.Settings.Llm.EnableAgentMemory))
{
memSvc.Add("correction",
$"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output ?? "", Defaults.LogSummaryMaxLength)}",
$"conv:{_conversationId}", context.WorkFolder);
}
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] 에이전트 메모리 저장 실패: {ex.Message}"); }
}
}
else
{
state.ConsecutiveErrors = 0;
// ToolResultSizer 적용
var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output));
// document_plan 호출 플래그 + 메타데이터 저장
if (call.ToolName == "document_plan" && result.Success)
{
state.DocumentPlanCalled = true;
try
{
var planJson = JsonDocument.Parse(result.Output ?? "{}");
if (planJson.RootElement.TryGetProperty("path", out var p))
state.DocumentPlanPath = p.GetString();
if (planJson.RootElement.TryGetProperty("title", out var t))
state.DocumentPlanTitle = t.GetString();
if (planJson.RootElement.TryGetProperty("body", out var b))
state.DocumentPlanScaffold = b.GetString();
}
catch (Exception) { /* document_plan JSON 파싱 실패 — 선택적 메타데이터이므로 무시 */ }
// html_create 즉시 호출 유도
var toolHint = (result.Output?.Contains("document_assemble") ?? false) ? "document_assemble" :
(result.Output?.Contains("file_write") ?? false) ? "file_write" : "html_create";
messages.Add(new ChatMessage { Role = "user",
Content = $"document_plan이 완료되었습니다. " +
$"위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 " +
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
$"각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요." });
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완성 — {toolHint} 호출 중...");
}
// 단일 문서 생성 도구 성공 → 즉시 루프 종료
if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1)
{
var shouldVerify2 = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
if (shouldVerify2)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (ToolCallAction.Return, result.Output ?? "");
}
// Post-Tool Verification: 탭별 검증 강제
var shouldVerify = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
if (shouldVerify && result.Success)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
}
}
return (ToolCallAction.Continue, null);
}
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
private enum ToolCallAction { Continue, Break, Return }
/// <summary>Phase 33-B: 도구 훅(Pre/Post) 실행 헬퍼.</summary>
private async Task RunToolHooksAsync(
Models.LlmSettings llm, LlmService.ContentBlock call,
AgentContext context, string phase, CancellationToken ct,
ToolResult? result = null)
{
if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return;
try
{
var hookResults = await AgentHookRunner.RunAsync(
llm.AgentHooks, call.ToolName, phase,
toolInput: call.ToolInput.ToString(),
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
success: result?.Success ?? false,
workFolder: context.WorkFolder,
timeoutMs: llm.ToolHookTimeoutMs,
ct: ct);
foreach (var pr in hookResults.Where(r => !r.Success))
EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}");
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 훅 실패: {ex.Message}"); }
}
/// <summary>DelegateAgentTool에서 호출하는 서브에이전트 실행기.</summary>
private async Task<string> RunSubAgentAsync(string agentType, string task, string workFolder, CancellationToken ct)
{
try
{
var systemPrompt = $"당신은 전문 {agentType} 에이전트입니다. 주어진 작업을 수행하세요.";
var msgs = new List<ChatMessage>
{
new() { Role = "system", Content = systemPrompt },
new() { Role = "user", Content = task }
};
// 서브에이전트는 간단한 텍스트 응답으로 처리 (도구 호출 없이)
var response = await _llm.SendAsync(msgs, ct);
return response;
}
catch (Exception ex)
{
return $"서브에이전트 실행 오류: {ex.Message}";
}
}
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls,
List<ChatMessage> messages,
AgentContext context,
List<string> planSteps,
ParallelState state,
int baseMax, int maxRetry,
Models.LlmSettings llm,
int iteration,
CancellationToken ct,
List<string> statsUsedTools)
{
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
var tasks = calls.Select(async call =>
{
var tool = _tools.Get(call.ToolName);
if (tool == null)
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await tool.ExecuteAsync(input, context, ct);
sw.Stop();
return (call, result, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
}
}).ToList();
var results = await Task.WhenAll(tasks);
// 결과를 순서대로 메시지에 추가
foreach (var (call, result, elapsed) in results)
{
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: elapsed,
iteration: iteration);
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
if (!statsUsedTools.Contains(call.ToolName))
statsUsedTools.Add(call.ToolName);
state.TotalToolCalls++;
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, TruncateOutput(result.Output, Defaults.ToolResultTruncateLength)));
if (!result.Success)
{
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors > maxRetry)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
$"[FAILED after retries] {TruncateOutput(result.Output, 500)}"));
}
}
else
{
state.ConsecutiveErrors = 0;
}
// 감사 로그
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, context.ActiveTab ?? "",
call.ToolName,
call.ToolInput?.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
}
}
}

View File

@@ -0,0 +1,469 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.ComponentModel;
using System.Windows.Forms;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.ViewModels;
public partial class SettingsViewModel
{
// ─── 인덱스 경로 메서드 ──────────────────────────────────────────────
public void AddIndexPath(string path)
{
var trimmed = path.Trim();
if (string.IsNullOrWhiteSpace(trimmed)) return;
if (IndexPaths.Any(p => p.Equals(trimmed, StringComparison.OrdinalIgnoreCase))) return;
IndexPaths.Add(trimmed);
}
public void RemoveIndexPath(string path) => IndexPaths.Remove(path);
// ─── 인덱스 확장자 메서드 ──────────────────────────────────────────
public void AddExtension(string ext)
{
var trimmed = ext.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(trimmed)) return;
if (!trimmed.StartsWith(".")) trimmed = "." + trimmed;
if (IndexExtensions.Any(e => e.Equals(trimmed, StringComparison.OrdinalIgnoreCase))) return;
IndexExtensions.Add(trimmed);
}
public void RemoveExtension(string ext) => IndexExtensions.Remove(ext);
public void BrowseIndexPath()
{
using var dlg = new FolderBrowserDialog
{
Description = "인덱스할 폴더 선택",
UseDescriptionForTitle = true,
ShowNewFolderButton = false
};
if (dlg.ShowDialog() != DialogResult.OK) return;
AddIndexPath(dlg.SelectedPath);
}
// ─── 빠른 실행 단축키 메서드 ──────────────────────────────────────────
public bool AddShortcut()
{
if (string.IsNullOrWhiteSpace(_newKey) || string.IsNullOrWhiteSpace(_newTarget))
return false;
// 중복 키 확인
if (AppShortcuts.Any(s => s.Key.Equals(_newKey.Trim(), StringComparison.OrdinalIgnoreCase)))
return false;
AppShortcuts.Add(new AppShortcutModel
{
Key = _newKey.Trim(),
Description = _newDescription.Trim(),
Target = _newTarget.Trim(),
Type = _newType
});
NewKey = ""; NewDescription = ""; NewTarget = ""; NewType = "app";
return true;
}
public void RemoveShortcut(AppShortcutModel shortcut) => AppShortcuts.Remove(shortcut);
/// <summary>파일 선택 대화상자. 선택 시 NewTarget에 자동 설정.</summary>
public void BrowseTarget()
{
using var dlg = new OpenFileDialog
{
Filter = "실행 파일 (*.exe)|*.exe|모든 파일 (*.*)|*.*",
Title = "앱 선택"
};
if (dlg.ShowDialog() != DialogResult.OK) return;
NewTarget = dlg.FileName;
NewType = "app";
if (string.IsNullOrWhiteSpace(NewDescription))
NewDescription = System.IO.Path.GetFileNameWithoutExtension(dlg.FileName);
}
public void SelectTheme(string key)
{
SelectedThemeKey = key;
ThemePreviewRequested?.Invoke(this, EventArgs.Empty);
}
public void PickColor(ColorRowModel row)
{
using var dlg = new ColorDialog { FullOpen = true };
try
{
var color = ThemeResourceHelper.HexColor(row.Hex);
dlg.Color = System.Drawing.Color.FromArgb(color.R, color.G, color.B);
}
catch (Exception) { /* 기본값 사용 */ }
if (dlg.ShowDialog() != DialogResult.OK) return;
row.Hex = $"#{dlg.Color.R:X2}{dlg.Color.G:X2}{dlg.Color.B:X2}";
if (_selectedThemeKey == "custom")
ThemePreviewRequested?.Invoke(this, EventArgs.Empty);
}
// 시스템 예약 프리픽스 (핸들러에서 이미 사용 중인 키)
private static readonly HashSet<string> ReservedPrefixes = new(StringComparer.OrdinalIgnoreCase)
{
"=", "?", "#", "$", ";", "@", "~", ">", "!",
"emoji", "color", "recent", "note", "uninstall", "kill", "media",
"info", "*", "json", "encode", "port", "env", "snap", "help",
"pick", "date", "svc", "pipe", "journal", "routine", "batch",
"diff", "win", "stats", "fav", "rename", "monitor", "scaffold",
};
/// <summary>설정 저장 전 프리픽스/키워드 충돌을 검사합니다. 충돌 시 메시지를 반환합니다.</summary>
public string? ValidateBeforeSave()
{
// 캡처 프리픽스 충돌 검사
var cap = string.IsNullOrWhiteSpace(_capPrefix) ? "cap" : _capPrefix.Trim();
if (cap != "cap" && ReservedPrefixes.Contains(cap))
return $"캡처 프리픽스 '{cap}'은(는) 이미 사용 중인 예약어입니다.";
// 빠른 실행 별칭 키 중복 검사
var aliasKeys = AppShortcuts.Select(s => s.Key.ToLowerInvariant()).ToList();
var duplicateAlias = aliasKeys.GroupBy(k => k).FirstOrDefault(g => g.Count() > 1);
if (duplicateAlias != null)
return $"빠른 실행 키워드 '{duplicateAlias.Key}'이(가) 중복되었습니다.";
// 빠른 실행 별칭 키 vs 예약 프리픽스 충돌
foreach (var key in aliasKeys)
{
if (ReservedPrefixes.Contains(key))
return $"빠른 실행 키워드 '{key}'은(는) 시스템 예약어와 충돌합니다.";
}
// 배치 명령 키 중복 검사
var batchKeys = BatchCommands.Select(b => b.Key.ToLowerInvariant()).ToList();
var duplicateBatch = batchKeys.GroupBy(k => k).FirstOrDefault(g => g.Count() > 1);
if (duplicateBatch != null)
return $"배치 명령 키워드 '{duplicateBatch.Key}'이(가) 중복되었습니다.";
return null; // 문제 없음
}
public void Save()
{
// 충돌 검사
var conflict = ValidateBeforeSave();
if (conflict != null)
{
CustomMessageBox.Show(
conflict,
"AX Copilot — 설정 저장 오류",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Warning);
return;
}
var s = _service.Settings;
s.Hotkey = _hotkey;
s.Launcher.MaxResults = _maxResults;
s.Launcher.Opacity = _opacity;
s.Launcher.Theme = _selectedThemeKey;
s.Launcher.Position = _launcherPosition;
s.Launcher.WebSearchEngine = _webSearchEngine;
s.Launcher.SnippetAutoExpand = _snippetAutoExpand;
s.Launcher.Language = _language;
L10n.SetLanguage(_language);
// 기능 토글 저장
s.Launcher.ShowNumberBadges = _showNumberBadges;
s.Launcher.EnableFavorites = _enableFavorites;
s.Launcher.EnableRecent = _enableRecent;
s.Launcher.EnableActionMode = _enableActionMode;
s.Launcher.CloseOnFocusLost = _closeOnFocusLost;
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
s.Launcher.EnableIconAnimation = _enableIconAnimation;
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
s.Launcher.EnableSelectionGlow = _enableSelectionGlow;
s.Launcher.EnableRandomPlaceholder = _enableRandomPlaceholder;
s.Launcher.ShowLauncherBorder = _showLauncherBorder;
s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor;
s.Launcher.EnableTextAction = _enableTextAction;
s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration;
s.Launcher.EnableClipboardAutoCategory = _enableClipboardAutoCategory;
s.Launcher.MaxPinnedClipboardItems = _maxPinnedClipboardItems;
s.Launcher.TextActionTranslateLanguage = _textActionTranslateLanguage;
s.Llm.MaxSubAgents = _maxSubAgents;
s.Llm.PdfExportPath = _pdfExportPath;
s.Llm.TipDurationSeconds = _tipDurationSeconds;
// LLM 공통 설정 저장
s.Llm.Service = _llmService;
s.Llm.Streaming = _llmStreaming;
s.Llm.MaxContextTokens = _llmMaxContextTokens;
s.Llm.RetentionDays = _llmRetentionDays;
s.Llm.Temperature = _llmTemperature;
s.Llm.DefaultAgentPermission = _defaultAgentPermission;
s.Llm.DefaultOutputFormat = _defaultOutputFormat;
s.Llm.DefaultMood = _defaultMood;
s.Llm.AutoPreview = _autoPreview;
s.Llm.MaxAgentIterations = _maxAgentIterations;
s.Llm.MaxRetryOnError = _maxRetryOnError;
s.Llm.AgentLogLevel = _agentLogLevel;
s.Llm.AgentDecisionLevel = _agentDecisionLevel;
s.Llm.PlanMode = _planMode;
s.Llm.EnableMultiPassDocument = _enableMultiPassDocument;
s.Llm.EnableCoworkVerification = _enableCoworkVerification;
s.Llm.EnableFilePathHighlight = _enableFilePathHighlight;
s.Llm.FolderDataUsage = _folderDataUsage;
s.Llm.EnableAuditLog = _enableAuditLog;
s.Llm.EnableAgentMemory = _enableAgentMemory;
s.Llm.EnableProjectRules = _enableProjectRules;
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
s.Llm.EnableImageInput = _enableImageInput;
s.Llm.MaxImageSizeKb = _maxImageSizeKb;
s.Llm.EnableToolHooks = _enableToolHooks;
s.Llm.ToolHookTimeoutMs = _toolHookTimeoutMs;
s.Llm.EnableSkillSystem = _enableSkillSystem;
s.Llm.SkillsFolderPath = _skillsFolderPath;
s.Llm.SlashPopupPageSize = _slashPopupPageSize;
s.Llm.EnableDragDropAiActions = _enableDragDropAiActions;
s.Llm.DragDropAutoSend = _dragDropAutoSend;
s.Llm.Code.EnableCodeReview = _enableCodeReview;
s.Llm.EnableAutoRouter = _enableAutoRouter;
s.Llm.AutoRouterConfidence = _autoRouterConfidence;
s.Llm.EnableChatRainbowGlow = _enableChatRainbowGlow;
s.Llm.NotifyOnComplete = _notifyOnComplete;
s.Llm.ShowTips = _showTips;
s.Llm.DevMode = _devMode;
s.Llm.DevModeStepApproval = _devModeStepApproval;
s.Llm.WorkflowVisualizer = _workflowVisualizer;
s.Llm.FreeTierMode = _freeTierMode;
s.Llm.FreeTierDelaySeconds = _freeTierDelaySeconds;
s.Llm.ShowTotalCallStats = _showTotalCallStats;
// 서비스별 독립 설정 저장
s.Llm.OllamaEndpoint = _ollamaEndpoint;
s.Llm.OllamaModel = _ollamaModel;
s.Llm.VllmEndpoint = _vllmEndpoint;
s.Llm.VllmModel = _vllmModel;
s.Llm.GeminiModel = _geminiModel;
s.Llm.ClaudeModel = _claudeModel;
s.Llm.GeminiApiKey = _geminiApiKey;
s.Llm.ClaudeApiKey = _claudeApiKey;
// 내부 서비스 API 키 저장 (암호화 분기)
if (!string.IsNullOrEmpty(_ollamaApiKey) && _ollamaApiKey != "(저장됨)")
s.Llm.OllamaApiKey = CryptoService.EncryptIfEnabled(_ollamaApiKey, s.Llm.EncryptionEnabled);
if (!string.IsNullOrEmpty(_vllmApiKey) && _vllmApiKey != "(저장됨)")
s.Llm.VllmApiKey = CryptoService.EncryptIfEnabled(_vllmApiKey, s.Llm.EncryptionEnabled);
// 활성 서비스의 설정을 기존 호환 필드에도 동기화 (LlmService.cs 호환)
switch (_llmService)
{
case "ollama":
s.Llm.Endpoint = _ollamaEndpoint;
s.Llm.Model = _ollamaModel;
s.Llm.EncryptedApiKey = s.Llm.OllamaApiKey;
break;
case "vllm":
s.Llm.Endpoint = _vllmEndpoint;
s.Llm.Model = _vllmModel;
s.Llm.EncryptedApiKey = s.Llm.VllmApiKey;
break;
case "gemini":
s.Llm.ApiKey = _geminiApiKey;
s.Llm.Model = _geminiModel;
break;
case "claude":
s.Llm.ApiKey = _claudeApiKey;
s.Llm.Model = _claudeModel;
break;
}
// 등록 모델 저장
s.Llm.RegisteredModels = RegisteredModels
.Where(rm => !string.IsNullOrWhiteSpace(rm.Alias))
.Select(rm => new RegisteredModel
{
Alias = rm.Alias,
EncryptedModelName = rm.EncryptedModelName,
Service = rm.Service,
Endpoint = rm.Endpoint,
ApiKey = rm.ApiKey,
AuthType = rm.AuthType ?? "bearer",
Cp4dUrl = rm.Cp4dUrl ?? "",
Cp4dUsername = rm.Cp4dUsername ?? "",
Cp4dPassword = rm.Cp4dPassword ?? "",
})
.ToList();
// 프롬프트 템플릿 저장
s.Llm.PromptTemplates = PromptTemplates
.Where(pt => !string.IsNullOrWhiteSpace(pt.Name))
.Select(pt => new PromptTemplate { Name = pt.Name, Content = pt.Content, Icon = pt.Icon })
.ToList();
// 인덱스 경로 + 확장자 저장
s.IndexPaths = IndexPaths.ToList();
s.IndexExtensions = IndexExtensions.ToList();
s.IndexSpeed = _indexSpeed;
// 커스텀 색상 + 모양 저장
var c = s.Launcher.CustomTheme ??= new CustomThemeColors();
foreach (var row in ColorRows)
{
var prop = typeof(CustomThemeColors).GetProperty(row.Property);
prop?.SetValue(c, row.Hex);
}
c.WindowCornerRadius = _customWindowCornerRadius;
c.ItemCornerRadius = _customItemCornerRadius;
// 빠른 실행 단축키 저장:
// batch/api/clipboard type은 그대로 유지, app/url/folder는 ViewModel 내용으로 교체
var otherAliases = s.Aliases
.Where(a => a.Type is not ("app" or "url" or "folder" or "batch"))
.ToList();
s.Aliases = otherAliases
.Concat(AppShortcuts.Select(sc => new AliasEntry
{
Key = sc.Key,
Type = sc.Type,
Target = sc.Target,
Description = string.IsNullOrWhiteSpace(sc.Description) ? null : sc.Description
}))
.Concat(BatchCommands.Select(b => new AliasEntry
{
Key = b.Key,
Type = "batch",
Target = b.Command,
ShowWindow = b.ShowWindow
}))
.ToList();
// 스니펫 저장
s.Snippets = Snippets.Select(sn => new SnippetEntry
{
Key = sn.Key,
Name = sn.Name,
Content = sn.Content
}).ToList();
// 알림 설정 저장
s.Reminder.Enabled = _reminderEnabled;
s.Reminder.Corner = _reminderCorner;
s.Reminder.IntervalMinutes = _reminderIntervalMinutes;
s.Reminder.DisplaySeconds = _reminderDisplaySeconds;
// 캡처 설정 저장
s.ScreenCapture.Prefix = string.IsNullOrWhiteSpace(_capPrefix) ? "cap" : _capPrefix.Trim();
s.ScreenCapture.GlobalHotkeyEnabled = _capGlobalHotkeyEnabled;
s.ScreenCapture.GlobalHotkey = string.IsNullOrWhiteSpace(_capGlobalHotkey) ? "PrintScreen" : _capGlobalHotkey.Trim();
s.ScreenCapture.GlobalHotkeyMode = _capGlobalMode;
s.ScreenCapture.ScrollDelayMs = Math.Max(50, _capScrollDelayMs);
// 클립보드 히스토리 설정 저장
s.ClipboardHistory.Enabled = _clipboardEnabled;
s.ClipboardHistory.MaxItems = _clipboardMaxItems;
// 시스템 명령 설정 저장
var sc = s.SystemCommands;
sc.ShowLock = _sysShowLock;
sc.ShowSleep = _sysShowSleep;
sc.ShowRestart = _sysShowRestart;
sc.ShowShutdown = _sysShowShutdown;
sc.ShowHibernate = _sysShowHibernate;
sc.ShowLogout = _sysShowLogout;
sc.ShowRecycleBin = _sysShowRecycleBin;
// 시스템 명령 별칭 저장
var cmdAliases = new Dictionary<string, List<string>>();
void SaveAlias(string key, string val)
{
var list = ParseAliases(val);
if (list.Count > 0) cmdAliases[key] = list;
}
SaveAlias("lock", _aliasLock);
SaveAlias("sleep", _aliasSleep);
SaveAlias("restart", _aliasRestart);
SaveAlias("shutdown", _aliasShutdown);
SaveAlias("hibernate", _aliasHibernate);
SaveAlias("logout", _aliasLogout);
SaveAlias("recycle", _aliasRecycle);
sc.CommandAliases = cmdAliases;
_service.Save();
SaveCompleted?.Invoke(this, EventArgs.Empty);
}
public bool AddBatchCommand()
{
if (string.IsNullOrWhiteSpace(_newBatchKey) || string.IsNullOrWhiteSpace(_newBatchCommand))
return false;
if (BatchCommands.Any(b => b.Key.Equals(_newBatchKey.Trim(), StringComparison.OrdinalIgnoreCase)))
return false;
BatchCommands.Add(new BatchCommandModel
{
Key = _newBatchKey.Trim(),
Command = _newBatchCommand.Trim(),
ShowWindow = _newBatchShowWindow
});
NewBatchKey = ""; NewBatchCommand = ""; NewBatchShowWindow = false;
return true;
}
public void RemoveBatchCommand(BatchCommandModel cmd) => BatchCommands.Remove(cmd);
// ─── 스니펫 메서드 ──────────────────────────────────────────────────────
public bool AddSnippet()
{
if (string.IsNullOrWhiteSpace(_newSnippetKey) || string.IsNullOrWhiteSpace(_newSnippetContent))
return false;
if (Snippets.Any(sn => sn.Key.Equals(_newSnippetKey.Trim(), StringComparison.OrdinalIgnoreCase)))
return false;
Snippets.Add(new SnippetRowModel
{
Key = _newSnippetKey.Trim(),
Name = _newSnippetName.Trim(),
Content = _newSnippetContent.Trim()
});
NewSnippetKey = ""; NewSnippetName = ""; NewSnippetContent = "";
return true;
}
public void RemoveSnippet(SnippetRowModel row) => Snippets.Remove(row);
// ─── 캡처 메서드 ────────────────────────────────────────────────────────
public void ResetCapPrefix() => CapPrefix = "cap";
public void ResetCapGlobalHotkey()
{
CapGlobalHotkey = "PrintScreen";
CapGlobalMode = "screen";
}
// ─── 알림 메서드 ────────────────────────────────────────────────────────
public List<string> GetReminderCategories() => _service.Settings.Reminder.EnabledCategories;
// ─── 시스템 명령 메서드 ─────────────────────────────────────────────────
public void ResetSystemCommandAliases()
{
AliasLock = ""; AliasSleep = ""; AliasRestart = "";
AliasShutdown = ""; AliasHibernate = ""; AliasLogout = ""; AliasRecycle = "";
}
private static string FormatAliases(Dictionary<string, List<string>> dict, string key)
=> dict.TryGetValue(key, out var list) ? string.Join(", ", list) : "";
private static List<string> ParseAliases(string input)
=> input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(n));
}

View File

@@ -0,0 +1,837 @@
using System.Collections.ObjectModel;
using AxCopilot.Models;
namespace AxCopilot.ViewModels;
public partial class SettingsViewModel
{
/// <summary>CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근.</summary>
public Models.CodeSettings Code => _service.Settings.Llm.Code;
// ─── 등록 모델 목록 ───────────────────────────────────────────────────
public ObservableCollection<RegisteredModelRow> RegisteredModels { get; } = new();
public string LlmService
{
get => _llmService;
set { _llmService = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsInternalService)); OnPropertyChanged(nameof(IsExternalService)); OnPropertyChanged(nameof(NeedsEndpoint)); OnPropertyChanged(nameof(NeedsApiKey)); OnPropertyChanged(nameof(IsGeminiSelected)); OnPropertyChanged(nameof(IsClaudeSelected)); }
}
public bool IsInternalService => _llmService is "ollama" or "vllm";
public bool IsExternalService => _llmService is "gemini" or "claude";
public bool NeedsEndpoint => _llmService is "ollama" or "vllm";
public bool NeedsApiKey => _llmService is not "ollama";
public bool IsGeminiSelected => _llmService == "gemini";
public bool IsClaudeSelected => _llmService == "claude";
// ── Ollama 설정 ──
public string OllamaEndpoint { get => _ollamaEndpoint; set { _ollamaEndpoint = value; OnPropertyChanged(); } }
public string OllamaApiKey { get => _ollamaApiKey; set { _ollamaApiKey = value; OnPropertyChanged(); } }
public string OllamaModel { get => _ollamaModel; set { _ollamaModel = value; OnPropertyChanged(); } }
// ── vLLM 설정 ──
public string VllmEndpoint { get => _vllmEndpoint; set { _vllmEndpoint = value; OnPropertyChanged(); } }
public string VllmApiKey { get => _vllmApiKey; set { _vllmApiKey = value; OnPropertyChanged(); } }
public string VllmModel { get => _vllmModel; set { _vllmModel = value; OnPropertyChanged(); } }
// ── Gemini 설정 ──
public string GeminiApiKey { get => _geminiApiKey; set { _geminiApiKey = value; OnPropertyChanged(); } }
public string GeminiModel { get => _geminiModel; set { _geminiModel = value; OnPropertyChanged(); } }
// ── Claude 설정 ──
public string ClaudeApiKey { get => _claudeApiKey; set { _claudeApiKey = value; OnPropertyChanged(); } }
public string ClaudeModel { get => _claudeModel; set { _claudeModel = value; OnPropertyChanged(); } }
// ── 공통 응답 설정 ──
public bool LlmStreaming
{
get => _llmStreaming;
set { _llmStreaming = value; OnPropertyChanged(); }
}
public int LlmMaxContextTokens
{
get => _llmMaxContextTokens;
set { _llmMaxContextTokens = value; OnPropertyChanged(); }
}
public int LlmRetentionDays
{
get => _llmRetentionDays;
set { _llmRetentionDays = value; OnPropertyChanged(); }
}
public double LlmTemperature
{
get => _llmTemperature;
set { _llmTemperature = Math.Round(Math.Clamp(value, 0.0, 2.0), 1); OnPropertyChanged(); }
}
// 에이전트 기본 파일 접근 권한
private string _defaultAgentPermission;
public string DefaultAgentPermission
{
get => _defaultAgentPermission;
set { _defaultAgentPermission = value; OnPropertyChanged(); }
}
// ── 코워크/에이전트 고급 설정 ──
private string _defaultOutputFormat;
public string DefaultOutputFormat
{
get => _defaultOutputFormat;
set { _defaultOutputFormat = value; OnPropertyChanged(); }
}
private string _autoPreview;
public string AutoPreview
{
get => _autoPreview;
set { _autoPreview = value; OnPropertyChanged(); }
}
private int _maxAgentIterations;
public int MaxAgentIterations
{
get => _maxAgentIterations;
set { _maxAgentIterations = Math.Clamp(value, 1, 100); OnPropertyChanged(); }
}
private int _maxRetryOnError;
public int MaxRetryOnError
{
get => _maxRetryOnError;
set { _maxRetryOnError = Math.Clamp(value, 0, 10); OnPropertyChanged(); }
}
private string _agentLogLevel;
public string AgentLogLevel
{
get => _agentLogLevel;
set { _agentLogLevel = value; OnPropertyChanged(); }
}
private string _agentDecisionLevel = "detailed";
public string AgentDecisionLevel
{
get => _agentDecisionLevel;
set { _agentDecisionLevel = value; OnPropertyChanged(); }
}
private string _planMode = "off";
public string PlanMode
{
get => _planMode;
set { _planMode = value; OnPropertyChanged(); }
}
private bool _enableMultiPassDocument;
public bool EnableMultiPassDocument
{
get => _enableMultiPassDocument;
set { _enableMultiPassDocument = value; OnPropertyChanged(); }
}
private bool _enableCoworkVerification;
public bool EnableCoworkVerification
{
get => _enableCoworkVerification;
set { _enableCoworkVerification = value; OnPropertyChanged(); }
}
private bool _enableFilePathHighlight = true;
public bool EnableFilePathHighlight
{
get => _enableFilePathHighlight;
set { _enableFilePathHighlight = value; OnPropertyChanged(); }
}
private string _folderDataUsage;
public string FolderDataUsage
{
get => _folderDataUsage;
set { _folderDataUsage = value; OnPropertyChanged(); }
}
// ── 모델 폴백 + 보안 + MCP ──
private bool _enableAuditLog;
public bool EnableAuditLog
{
get => _enableAuditLog;
set { _enableAuditLog = value; OnPropertyChanged(); }
}
private bool _enableAgentMemory;
public bool EnableAgentMemory
{
get => _enableAgentMemory;
set { _enableAgentMemory = value; OnPropertyChanged(); }
}
private bool _enableProjectRules = true;
public bool EnableProjectRules
{
get => _enableProjectRules;
set { _enableProjectRules = value; OnPropertyChanged(); }
}
private int _maxMemoryEntries;
public int MaxMemoryEntries
{
get => _maxMemoryEntries;
set { _maxMemoryEntries = value; OnPropertyChanged(); }
}
// ── 이미지 입력 (멀티모달) ──
private bool _enableImageInput = true;
public bool EnableImageInput
{
get => _enableImageInput;
set { _enableImageInput = value; OnPropertyChanged(); }
}
private int _maxImageSizeKb = 5120;
public int MaxImageSizeKb
{
get => _maxImageSizeKb;
set { _maxImageSizeKb = value; OnPropertyChanged(); }
}
// ── 자동 모델 라우팅 ──
private bool _enableAutoRouter;
public bool EnableAutoRouter
{
get => _enableAutoRouter;
set { _enableAutoRouter = value; OnPropertyChanged(); }
}
private double _autoRouterConfidence = 0.7;
public double AutoRouterConfidence
{
get => _autoRouterConfidence;
set { _autoRouterConfidence = value; OnPropertyChanged(); }
}
// ── 에이전트 훅 시스템 ──
private bool _enableToolHooks = true;
public bool EnableToolHooks
{
get => _enableToolHooks;
set { _enableToolHooks = value; OnPropertyChanged(); }
}
private int _toolHookTimeoutMs = 10000;
public int ToolHookTimeoutMs
{
get => _toolHookTimeoutMs;
set { _toolHookTimeoutMs = value; OnPropertyChanged(); }
}
// ── 스킬 시스템 ──
private bool _enableSkillSystem = true;
public bool EnableSkillSystem
{
get => _enableSkillSystem;
set { _enableSkillSystem = value; OnPropertyChanged(); }
}
private string _skillsFolderPath = "";
public string SkillsFolderPath
{
get => _skillsFolderPath;
set { _skillsFolderPath = value; OnPropertyChanged(); }
}
private int _slashPopupPageSize = 6;
public int SlashPopupPageSize
{
get => _slashPopupPageSize;
set { _slashPopupPageSize = Math.Clamp(value, 3, 10); OnPropertyChanged(); }
}
// ── 드래그&드롭 AI ──
private bool _enableDragDropAiActions = true;
public bool EnableDragDropAiActions
{
get => _enableDragDropAiActions;
set { _enableDragDropAiActions = value; OnPropertyChanged(); }
}
private bool _dragDropAutoSend;
public bool DragDropAutoSend
{
get => _dragDropAutoSend;
set { _dragDropAutoSend = value; OnPropertyChanged(); }
}
// ── 코드 리뷰 ──
private bool _enableCodeReview = true;
public bool EnableCodeReview
{
get => _enableCodeReview;
set { _enableCodeReview = value; OnPropertyChanged(); }
}
// ── 시각 효과 + 알림 + 개발자 모드 (공통) ──
private bool _enableChatRainbowGlow;
public bool EnableChatRainbowGlow
{
get => _enableChatRainbowGlow;
set { _enableChatRainbowGlow = value; OnPropertyChanged(); }
}
private bool _notifyOnComplete;
public bool NotifyOnComplete
{
get => _notifyOnComplete;
set { _notifyOnComplete = value; OnPropertyChanged(); }
}
private bool _showTips;
public bool ShowTips
{
get => _showTips;
set { _showTips = value; OnPropertyChanged(); }
}
private bool _devMode;
public bool DevMode
{
get => _devMode;
set { _devMode = value; OnPropertyChanged(); }
}
private bool _devModeStepApproval;
public bool DevModeStepApproval
{
get => _devModeStepApproval;
set { _devModeStepApproval = value; OnPropertyChanged(); }
}
private bool _workflowVisualizer;
public bool WorkflowVisualizer
{
get => _workflowVisualizer;
set { _workflowVisualizer = value; OnPropertyChanged(); }
}
private bool _freeTierMode;
public bool FreeTierMode
{
get => _freeTierMode;
set { _freeTierMode = value; OnPropertyChanged(); }
}
private int _freeTierDelaySeconds = 4;
public int FreeTierDelaySeconds
{
get => _freeTierDelaySeconds;
set { _freeTierDelaySeconds = value; OnPropertyChanged(); }
}
private bool _showTotalCallStats;
public bool ShowTotalCallStats
{
get => _showTotalCallStats;
set { _showTotalCallStats = value; OnPropertyChanged(); }
}
private string _defaultMood = "modern";
public string DefaultMood
{
get => _defaultMood;
set { _defaultMood = value; OnPropertyChanged(); }
}
// 차단 경로/확장자 (읽기 전용 UI)
public ObservableCollection<string> BlockedPaths { get; } = new();
public ObservableCollection<string> BlockedExtensions { get; } = new();
public string Hotkey
{
get => _hotkey;
set { _hotkey = value; OnPropertyChanged(); }
}
public int MaxResults
{
get => _maxResults;
set { _maxResults = value; OnPropertyChanged(); }
}
public double Opacity
{
get => _opacity;
set { _opacity = value; OnPropertyChanged(); OnPropertyChanged(nameof(OpacityPercent)); }
}
public int OpacityPercent => (int)Math.Round(_opacity * 100);
public string SelectedThemeKey
{
get => _selectedThemeKey;
set
{
_selectedThemeKey = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsCustomTheme));
foreach (var card in ThemeCards)
card.IsSelected = card.Key == value;
}
}
public bool IsCustomTheme => _selectedThemeKey == "custom";
public string LauncherPosition
{
get => _launcherPosition;
set { _launcherPosition = value; OnPropertyChanged(); }
}
public string WebSearchEngine
{
get => _webSearchEngine;
set { _webSearchEngine = value; OnPropertyChanged(); }
}
public bool SnippetAutoExpand
{
get => _snippetAutoExpand;
set { _snippetAutoExpand = value; OnPropertyChanged(); }
}
public string Language
{
get => _language;
set { _language = value; OnPropertyChanged(); }
}
public string IndexSpeed
{
get => _indexSpeed;
set { _indexSpeed = value; OnPropertyChanged(); OnPropertyChanged(nameof(IndexSpeedHint)); }
}
public string IndexSpeedHint => _indexSpeed switch
{
"fast" => "CPU 사용률이 높아질 수 있습니다. 고성능 PC에 권장합니다.",
"slow" => "인덱싱이 오래 걸리지만 PC 성능에 영향을 주지 않습니다.",
_ => "일반적인 PC에 적합한 균형 설정입니다.",
};
// ─── 기능 토글 속성 ───────────────────────────────────────────────────
public bool ShowNumberBadges
{
get => _showNumberBadges;
set { _showNumberBadges = value; OnPropertyChanged(); }
}
public bool EnableFavorites
{
get => _enableFavorites;
set { _enableFavorites = value; OnPropertyChanged(); }
}
public bool EnableRecent
{
get => _enableRecent;
set { _enableRecent = value; OnPropertyChanged(); }
}
public bool EnableActionMode
{
get => _enableActionMode;
set { _enableActionMode = value; OnPropertyChanged(); }
}
public bool CloseOnFocusLost
{
get => _closeOnFocusLost;
set { _closeOnFocusLost = value; OnPropertyChanged(); }
}
public bool ShowPrefixBadge
{
get => _showPrefixBadge;
set { _showPrefixBadge = value; OnPropertyChanged(); }
}
public bool EnableIconAnimation
{
get => _enableIconAnimation;
set { _enableIconAnimation = value; OnPropertyChanged(); }
}
public bool EnableRainbowGlow
{
get => _enableRainbowGlow;
set { _enableRainbowGlow = value; OnPropertyChanged(); }
}
public bool EnableSelectionGlow
{
get => _enableSelectionGlow;
set { _enableSelectionGlow = value; OnPropertyChanged(); }
}
public bool EnableRandomPlaceholder
{
get => _enableRandomPlaceholder;
set { _enableRandomPlaceholder = value; OnPropertyChanged(); }
}
public bool ShowLauncherBorder
{
get => _showLauncherBorder;
set { _showLauncherBorder = value; OnPropertyChanged(); }
}
// ─── v1.4.0 신기능 설정 ──────────────────────────────────────────────────
private bool _enableTextAction = true;
public bool EnableTextAction
{
get => _enableTextAction;
set { _enableTextAction = value; OnPropertyChanged(); }
}
private bool _enableFileDialogIntegration = false;
public bool EnableFileDialogIntegration
{
get => _enableFileDialogIntegration;
set { _enableFileDialogIntegration = value; OnPropertyChanged(); }
}
private bool _enableClipboardAutoCategory = true;
public bool EnableClipboardAutoCategory
{
get => _enableClipboardAutoCategory;
set { _enableClipboardAutoCategory = value; OnPropertyChanged(); }
}
private int _maxPinnedClipboardItems = 20;
public int MaxPinnedClipboardItems
{
get => _maxPinnedClipboardItems;
set { _maxPinnedClipboardItems = value; OnPropertyChanged(); }
}
private string _textActionTranslateLanguage = "auto";
public string TextActionTranslateLanguage
{
get => _textActionTranslateLanguage;
set { _textActionTranslateLanguage = value; OnPropertyChanged(); }
}
private int _maxSubAgents = 3;
public int MaxSubAgents
{
get => _maxSubAgents;
set { _maxSubAgents = value; OnPropertyChanged(); }
}
private string _pdfExportPath = "";
public string PdfExportPath
{
get => _pdfExportPath;
set { _pdfExportPath = value; OnPropertyChanged(); }
}
private int _tipDurationSeconds = 5;
public int TipDurationSeconds
{
get => _tipDurationSeconds;
set { _tipDurationSeconds = value; OnPropertyChanged(); }
}
public bool ShortcutHelpUseThemeColor
{
get => _shortcutHelpUseThemeColor;
set { _shortcutHelpUseThemeColor = value; OnPropertyChanged(); }
}
// ─── 테마 카드 목록 ───────────────────────────────────────────────────
public List<ThemeCardModel> ThemeCards { get; } = new()
{
new()
{
Key = "system", Name = "시스템",
PreviewBackground = "#1E1E1E", PreviewText = "#FFFFFF",
PreviewSubText = "#888888", PreviewAccent = "#0078D4",
PreviewItem = "#2D2D2D", PreviewBorder = "#3D3D3D"
},
new()
{
Key = "dark", Name = "Dark",
PreviewBackground = "#1A1B2E", PreviewText = "#F0F0FF",
PreviewSubText = "#7A7D9C", PreviewAccent = "#4B5EFC",
PreviewItem = "#252637", PreviewBorder = "#2E2F4A"
},
new()
{
Key = "light", Name = "Light",
PreviewBackground = "#FAFAFA", PreviewText = "#1A1B2E",
PreviewSubText = "#666680", PreviewAccent = "#4B5EFC",
PreviewItem = "#F0F0F8", PreviewBorder = "#E0E0F0"
},
new()
{
Key = "oled", Name = "OLED",
PreviewBackground = "#000000", PreviewText = "#FFFFFF",
PreviewSubText = "#888899", PreviewAccent = "#5C6EFF",
PreviewItem = "#0A0A14", PreviewBorder = "#1A1A2E"
},
new()
{
Key = "nord", Name = "Nord",
PreviewBackground = "#2E3440", PreviewText = "#ECEFF4",
PreviewSubText = "#D8DEE9", PreviewAccent = "#88C0D0",
PreviewItem = "#3B4252", PreviewBorder = "#434C5E"
},
new()
{
Key = "monokai", Name = "Monokai",
PreviewBackground = "#272822", PreviewText = "#F8F8F2",
PreviewSubText = "#75715E", PreviewAccent = "#A6E22E",
PreviewItem = "#3E3D32", PreviewBorder = "#49483E"
},
new()
{
Key = "catppuccin", Name = "Catppuccin",
PreviewBackground = "#1E1E2E", PreviewText = "#CDD6F4",
PreviewSubText = "#A6ADC8", PreviewAccent = "#CBA6F7",
PreviewItem = "#313244", PreviewBorder = "#45475A"
},
new()
{
Key = "sepia", Name = "Sepia",
PreviewBackground = "#F5EFE0", PreviewText = "#3C2F1A",
PreviewSubText = "#7A6040", PreviewAccent = "#C0822A",
PreviewItem = "#EDE6D6", PreviewBorder = "#D8CCBA"
},
new()
{
Key = "alfred", Name = "Indigo",
PreviewBackground = "#26273B", PreviewText = "#EEEEFF",
PreviewSubText = "#8888BB", PreviewAccent = "#8877EE",
PreviewItem = "#3B3D60", PreviewBorder = "#40416A"
},
new()
{
Key = "alfredlight", Name = "Frost",
PreviewBackground = "#FFFFFF", PreviewText = "#1A1A2E",
PreviewSubText = "#9090AA", PreviewAccent = "#5555EE",
PreviewItem = "#E8E9FF", PreviewBorder = "#DCDCEE"
},
new()
{
Key = "codex", Name = "Codex",
PreviewBackground = "#FFFFFF", PreviewText = "#111111",
PreviewSubText = "#6B7280", PreviewAccent = "#7C3AED",
PreviewItem = "#F5F5F7", PreviewBorder = "#E5E7EB"
},
new()
{
Key = "custom", Name = "커스텀",
PreviewBackground = "#1A1B2E", PreviewText = "#F0F0FF",
PreviewSubText = "#7A7D9C", PreviewAccent = "#4B5EFC",
PreviewItem = "#252637", PreviewBorder = "#2E2F4A"
},
};
// ─── 커스텀 색상 행 목록 ──────────────────────────────────────────────
public List<ColorRowModel> ColorRows { get; }
// ─── 프롬프트 템플릿 ──────────────────────────────────────────────────
public ObservableCollection<PromptTemplateRow> PromptTemplates { get; } = new();
// ─── 배치 명령 ────────────────────────────────────────────────────────
public ObservableCollection<BatchCommandModel> BatchCommands { get; } = new();
private string _newBatchKey = "";
private string _newBatchCommand = "";
private bool _newBatchShowWindow;
public string NewBatchKey
{
get => _newBatchKey;
set { _newBatchKey = value; OnPropertyChanged(); }
}
public string NewBatchCommand
{
get => _newBatchCommand;
set { _newBatchCommand = value; OnPropertyChanged(); }
}
public bool NewBatchShowWindow
{
get => _newBatchShowWindow;
set { _newBatchShowWindow = value; OnPropertyChanged(); }
}
// ─── 빠른 실행 단축키 ─────────────────────────────────────────────────
public ObservableCollection<AppShortcutModel> AppShortcuts { get; } = new();
private string _newKey = "";
private string _newDescription = "";
private string _newTarget = "";
private string _newType = "app";
public string NewKey { get => _newKey; set { _newKey = value; OnPropertyChanged(); } }
public string NewDescription { get => _newDescription; set { _newDescription = value; OnPropertyChanged(); } }
public string NewTarget { get => _newTarget; set { _newTarget = value; OnPropertyChanged(); } }
public string NewType { get => _newType; set { _newType = value; OnPropertyChanged(); } }
// ─── 커스텀 테마 모서리 라운딩 ───────────────────────────────────────────
private int _customWindowCornerRadius = 20;
private int _customItemCornerRadius = 10;
public int CustomWindowCornerRadius
{
get => _customWindowCornerRadius;
set { _customWindowCornerRadius = Math.Clamp(value, 0, 30); OnPropertyChanged(); }
}
public int CustomItemCornerRadius
{
get => _customItemCornerRadius;
set { _customItemCornerRadius = Math.Clamp(value, 0, 20); OnPropertyChanged(); }
}
// ─── 인덱스 경로 ──────────────────────────────────────────────────────
public ObservableCollection<string> IndexPaths { get; } = new();
// ─── 인덱스 확장자 ──────────────────────────────────────────────────
public ObservableCollection<string> IndexExtensions { get; } = new();
// ─── 스니펫 설정 ──────────────────────────────────────────────────────────
public ObservableCollection<SnippetRowModel> Snippets { get; } = new();
private string _newSnippetKey = "";
private string _newSnippetName = "";
private string _newSnippetContent = "";
public string NewSnippetKey { get => _newSnippetKey; set { _newSnippetKey = value; OnPropertyChanged(); } }
public string NewSnippetName { get => _newSnippetName; set { _newSnippetName = value; OnPropertyChanged(); } }
public string NewSnippetContent { get => _newSnippetContent; set { _newSnippetContent = value; OnPropertyChanged(); } }
// ─── 클립보드 히스토리 설정 ────────────────────────────────────────────────
private bool _clipboardEnabled;
private int _clipboardMaxItems;
public bool ClipboardEnabled
{
get => _clipboardEnabled;
set { _clipboardEnabled = value; OnPropertyChanged(); }
}
public int ClipboardMaxItems
{
get => _clipboardMaxItems;
set { _clipboardMaxItems = value; OnPropertyChanged(); }
}
// ─── 스크린 캡처 설정 ──────────────────────────────────────────────────────
private string _capPrefix = "cap";
private bool _capGlobalHotkeyEnabled;
private string _capGlobalHotkey = "PrintScreen";
private string _capGlobalMode = "screen";
private int _capScrollDelayMs = 120;
public string CapPrefix
{
get => _capPrefix;
set { _capPrefix = value; OnPropertyChanged(); }
}
public bool CapGlobalHotkeyEnabled
{
get => _capGlobalHotkeyEnabled;
set { _capGlobalHotkeyEnabled = value; OnPropertyChanged(); }
}
public string CapGlobalHotkey
{
get => _capGlobalHotkey;
set { _capGlobalHotkey = value; OnPropertyChanged(); }
}
public string CapGlobalMode
{
get => _capGlobalMode;
set { _capGlobalMode = value; OnPropertyChanged(); }
}
public int CapScrollDelayMs
{
get => _capScrollDelayMs;
set { _capScrollDelayMs = value; OnPropertyChanged(); OnPropertyChanged(nameof(CapScrollDelayMsStr)); }
}
/// <summary>ComboBox SelectedValue와 string 바인딩 용도</summary>
public string CapScrollDelayMsStr
{
get => _capScrollDelayMs.ToString();
set { if (int.TryParse(value, out int v)) CapScrollDelayMs = v; }
}
// ─── 잠금 해제 알림 설정 ──────────────────────────────────────────────────
private bool _reminderEnabled;
private string _reminderCorner = "bottom-right";
private int _reminderIntervalMinutes = 60;
private int _reminderDisplaySeconds = 15;
public bool ReminderEnabled
{
get => _reminderEnabled;
set { _reminderEnabled = value; OnPropertyChanged(); }
}
public string ReminderCorner
{
get => _reminderCorner;
set { _reminderCorner = value; OnPropertyChanged(); }
}
public int ReminderIntervalMinutes
{
get => _reminderIntervalMinutes;
set { _reminderIntervalMinutes = value; OnPropertyChanged(); }
}
public int ReminderDisplaySeconds
{
get => _reminderDisplaySeconds;
set { _reminderDisplaySeconds = value; OnPropertyChanged(); }
}
// ─── 시스템 명령 설정 ──────────────────────────────────────────────────────
private bool _sysShowLock;
private bool _sysShowSleep;
private bool _sysShowRestart;
private bool _sysShowShutdown;
private bool _sysShowHibernate;
private bool _sysShowLogout;
private bool _sysShowRecycleBin;
public bool SysShowLock { get => _sysShowLock; set { _sysShowLock = value; OnPropertyChanged(); } }
public bool SysShowSleep { get => _sysShowSleep; set { _sysShowSleep = value; OnPropertyChanged(); } }
public bool SysShowRestart { get => _sysShowRestart; set { _sysShowRestart = value; OnPropertyChanged(); } }
public bool SysShowShutdown { get => _sysShowShutdown; set { _sysShowShutdown = value; OnPropertyChanged(); } }
public bool SysShowHibernate { get => _sysShowHibernate; set { _sysShowHibernate = value; OnPropertyChanged(); } }
public bool SysShowLogout { get => _sysShowLogout; set { _sysShowLogout = value; OnPropertyChanged(); } }
public bool SysShowRecycleBin { get => _sysShowRecycleBin; set { _sysShowRecycleBin = value; OnPropertyChanged(); } }
// ─── 시스템 명령 별칭 (쉼표 구분 문자열) ───────────────────────────────────
private string _aliasLock = "";
private string _aliasSleep = "";
private string _aliasRestart = "";
private string _aliasShutdown = "";
private string _aliasHibernate = "";
private string _aliasLogout = "";
private string _aliasRecycle = "";
public string AliasLock { get => _aliasLock; set { _aliasLock = value; OnPropertyChanged(); } }
public string AliasSleep { get => _aliasSleep; set { _aliasSleep = value; OnPropertyChanged(); } }
public string AliasRestart { get => _aliasRestart; set { _aliasRestart = value; OnPropertyChanged(); } }
public string AliasShutdown { get => _aliasShutdown; set { _aliasShutdown = value; OnPropertyChanged(); } }
public string AliasHibernate { get => _aliasHibernate; set { _aliasHibernate = value; OnPropertyChanged(); } }
public string AliasLogout { get => _aliasLogout; set { _aliasLogout = value; OnPropertyChanged(); } }
public string AliasRecycle { get => _aliasRecycle; set { _aliasRecycle = value; OnPropertyChanged(); } }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.ViewModels;
/// <summary>
/// 설정 카드에 표시할 테마 미리보기 데이터
/// </summary>
public class ThemeCardModel : INotifyPropertyChanged
{
private bool _isSelected;
public string Key { get; init; } = ""; // settings.json 값 (dark, light, ...)
public string Name { get; init; } = ""; // 표시 이름
// 미리보기용 색상
public string PreviewBackground { get; init; } = "#1A1B2E";
public string PreviewText { get; init; } = "#F0F0FF";
public string PreviewSubText { get; init; } = "#7A7D9C";
public string PreviewAccent { get; init; } = "#4B5EFC";
public string PreviewItem { get; init; } = "#252637";
public string PreviewBorder { get; init; } = "#2E2F4A";
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
/// <summary>
/// 커스텀 테마 단일 색상 항목 (색상 편집 탭 1행 1색)
/// </summary>
public class ColorRowModel : INotifyPropertyChanged
{
private string _hex;
public string Label { get; init; } = "";
public string Property { get; init; } = ""; // CustomThemeColors의 속성명
public string Hex
{
get => _hex;
set { _hex = value; OnPropertyChanged(); OnPropertyChanged(nameof(Preview)); }
}
public SolidColorBrush Preview
{
get
{
try { return ThemeResourceHelper.HexBrush(Hex); }
catch (Exception) { return new SolidColorBrush(Colors.Transparent); }
}
}
public ColorRowModel(string label, string property, string hex)
{
Label = label;
Property = property;
_hex = hex;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
/// <summary>
/// 빠른 실행 단축키 항목 (키워드 → 앱/URL/폴더)
/// </summary>
public class AppShortcutModel : INotifyPropertyChanged
{
private string _key = "";
private string _description = "";
private string _target = "";
private string _type = "app";
public string Key { get => _key; set { _key = value; OnPropertyChanged(); } }
public string Description { get => _description; set { _description = value; OnPropertyChanged(); } }
public string Target { get => _target; set { _target = value; OnPropertyChanged(); } }
public string Type { get => _type; set { _type = value; OnPropertyChanged(); OnPropertyChanged(nameof(TypeSymbol)); OnPropertyChanged(nameof(TypeLabel)); } }
public string TypeSymbol => Type switch
{
"url" => Symbols.Globe,
"folder" => Symbols.Folder,
_ => Symbols.App
};
public string TypeLabel => Type switch
{
"url" => "URL",
"folder" => "폴더",
_ => "앱"
};
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
/// <summary>
/// 배치 명령 항목 (> 프리픽스)
/// </summary>
public class BatchCommandModel : INotifyPropertyChanged
{
private string _key = "";
private string _command = "";
private bool _showWindow;
public string Key { get => _key; set { _key = value; OnPropertyChanged(); } }
public string Command { get => _command; set { _command = value; OnPropertyChanged(); } }
public bool ShowWindow { get => _showWindow; set { _showWindow = value; OnPropertyChanged(); } }
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
// ─── 프롬프트 템플릿 행 모델 ─────────────────────────────────────────────────────
public class PromptTemplateRow : INotifyPropertyChanged
{
private string _name = "";
private string _content = "";
private string _icon = "\uE8BD";
public string Name { get => _name; set { _name = value; OnPropertyChanged(); } }
public string Content { get => _content; set { _content = value; OnPropertyChanged(); OnPropertyChanged(nameof(Preview)); } }
public string Icon { get => _icon; set { _icon = value; OnPropertyChanged(); } }
public string Preview => Content.Length > 60
? Content[..57].Replace("\n", " ") + "…"
: Content.Replace("\n", " ");
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
// ─── 등록 모델 행 모델 ──────────────────────────────────────────────────────────
public class RegisteredModelRow : INotifyPropertyChanged
{
private string _alias = "";
private string _encryptedModelName = "";
private string _service = "ollama";
private string _endpoint = "";
private string _apiKey = "";
/// <summary>UI 표시용 별칭</summary>
public string Alias
{
get => _alias;
set { _alias = value; OnPropertyChanged(); }
}
/// <summary>암호화된 실제 모델명 (PortableEncrypt)</summary>
public string EncryptedModelName
{
get => _encryptedModelName;
set { _encryptedModelName = value; OnPropertyChanged(); OnPropertyChanged(nameof(MaskedModelName)); }
}
/// <summary>서비스 타입 (ollama / vllm)</summary>
public string Service
{
get => _service;
set { _service = value; OnPropertyChanged(); OnPropertyChanged(nameof(ServiceLabel)); }
}
/// <summary>이 모델 전용 서버 엔드포인트. 비어있으면 기본 엔드포인트 사용.</summary>
public string Endpoint
{
get => _endpoint;
set { _endpoint = value; OnPropertyChanged(); OnPropertyChanged(nameof(EndpointDisplay)); }
}
/// <summary>이 모델 전용 API 키. 비어있으면 기본 API 키 사용.</summary>
public string ApiKey
{
get => _apiKey;
set { _apiKey = value; OnPropertyChanged(); }
}
// ── CP4D 인증 필드 ──────────────────────────────────────────────────
private string _authType = "bearer";
private string _cp4dUrl = "";
private string _cp4dUsername = "";
private string _cp4dPassword = "";
/// <summary>인증 방식. bearer | cp4d</summary>
public string AuthType
{
get => _authType;
set { _authType = value; OnPropertyChanged(); OnPropertyChanged(nameof(AuthLabel)); }
}
/// <summary>CP4D 서버 URL</summary>
public string Cp4dUrl
{
get => _cp4dUrl;
set { _cp4dUrl = value; OnPropertyChanged(); }
}
/// <summary>CP4D 사용자 이름</summary>
public string Cp4dUsername
{
get => _cp4dUsername;
set { _cp4dUsername = value; OnPropertyChanged(); }
}
/// <summary>CP4D 비밀번호 (암호화 저장)</summary>
public string Cp4dPassword
{
get => _cp4dPassword;
set { _cp4dPassword = value; OnPropertyChanged(); }
}
/// <summary>인증 방식 라벨</summary>
public string AuthLabel => _authType == "cp4d" ? "CP4D" : "Bearer";
/// <summary>UI에 표시할 엔드포인트 요약</summary>
public string EndpointDisplay => string.IsNullOrEmpty(_endpoint) ? "(기본 서버)" : _endpoint;
/// <summary>UI에 표시할 마스킹된 모델명</summary>
public string MaskedModelName => string.IsNullOrEmpty(_encryptedModelName) ? "(미등록)" : "••••••••";
/// <summary>서비스 라벨</summary>
public string ServiceLabel => _service == "vllm" ? "vLLM" : "Ollama";
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
// ─── 스니펫 행 모델 ────────────────────────────────────────────────────────────
public class SnippetRowModel : INotifyPropertyChanged
{
private string _key = "";
private string _name = "";
private string _content = "";
public string Key { get => _key; set { _key = value; OnPropertyChanged(); } }
public string Name { get => _name; set { _name = value; OnPropertyChanged(); } }
public string Content { get => _content; set { _content = value; OnPropertyChanged(); } }
public string Preview => Content.Length > 50
? Content[..47].Replace("\n", " ") + "…"
: Content.Replace("\n", " ");
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}