[Phase46] 대형 파일 분할 리팩터링 2차 — 19개 신규 파셜 파일 생성

## 분할 대상 및 결과

### AgentLoopService.cs (1,334줄 → 846줄)
- AgentLoopService.HtmlReport.cs (151줄): AutoSaveAsHtml, ConvertTextToHtml, EscapeHtml
- AgentLoopService.Verification.cs (349줄): 도구 분류 판별 + RunPostToolVerificationAsync + EmitEvent + CheckDecisionRequired + FormatToolCallSummary

### ChatWindow 분할 (8개 신규 파셜 파일)
- ChatWindow.PlanViewer.cs (474줄): 계획 뷰어 — AddPlanningCard, AddDecisionButtons, CollapseDecisionButtons, ShowPlanButton 등 8개 메서드
- ChatWindow.EventBanner.cs (411줄): AddAgentEventBanner, BuildFileQuickActions
- ChatWindow.TaskDecomposition.cs (1,170줄 → 307줄): RenderSuggestActionChips, BuildFeedbackContext, UpdateProgressBar, BuildDiffView 잔류
- ChatWindow.BottomBar.cs (345줄): BuildBottomBar, BuildCodeBottomBar, ShowLogLevelMenu, ShowLanguageMenu 등
- ChatWindow.MoodMenu.cs (456줄): ShowFormatMenu, ShowMoodMenu, ShowCustomMoodDialog 등
- ChatWindow.CustomPresets.cs (978줄 → 203줄): ShowCustomPresetDialog, SelectTopic 잔류
- ChatWindow.ConversationMenu.cs (255줄): ShowConversationMenu (카테고리/삭제/즐겨찾기 팝업)
- ChatWindow.ConversationTitleEdit.cs (108줄): EnterTitleEditMode

### SettingsViewModel 분할
- SettingsViewModel.LlmProperties.cs (417줄): LLM·에이전트 관련 바인딩 프로퍼티
- SettingsViewModel.Properties.cs (837줄 → 427줄): 기능 토글·테마·스니펫 등 앱 수준 프로퍼티

### TemplateService 분할
- TemplateService.Css.cs (559줄): 11종 CSS 테마 문자열 상수
- TemplateService.cs (734줄 → 179줄): 메서드 로직만 잔류

### PlanViewerWindow 분할
- PlanViewerWindow.StepRenderer.cs (616줄): RenderSteps + SwapSteps + EditStep + 버튼 빌더 9개
- PlanViewerWindow.cs (931줄 → 324줄): Win32/생성자/공개 API 잔류

### App.xaml.cs 분할 (776줄 → 452줄)
- App.Settings.cs (252줄): SetupTrayIcon, OpenSettings, ToggleDockBar, RefreshDockBar, OpenAiChat
- App.Helpers.cs (92줄): LoadAppIcon, IsAutoStartEnabled, SetAutoStart, OnExit

### LlmService.ToolUse.cs 분할 (719줄 → 115줄)
- LlmService.ClaudeTools.cs (180줄): SendClaudeWithToolsAsync, BuildClaudeToolBody
- LlmService.GeminiTools.cs (175줄): SendGeminiWithToolsAsync, BuildGeminiToolBody
- LlmService.OpenAiTools.cs (215줄): SendOpenAiWithToolsAsync, BuildOpenAiToolBody

### SettingsWindow.UI.cs 분할 (802줄 → 310줄)
- SettingsWindow.Storage.cs (167줄): RefreshStorageInfo, BtnStorageCleanup_Click 등
- SettingsWindow.HotkeyUI.cs (127줄): RefreshHotkeyBadges, EnsureHotkeyInCombo, GetKeyName 등
- SettingsWindow.DevMode.cs (90줄): DevModeCheckBox_Checked, UpdateDevModeContentVisibility

## 빌드 결과: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 20:51:26 +09:00
parent f5a1ba999c
commit aa907d7b79
29 changed files with 5540 additions and 5365 deletions

View File

@@ -824,349 +824,6 @@ public partial class AgentLoopService
}
}
/// <summary>LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다.</summary>
private string? AutoSaveAsHtml(string textContent, string userQuery, AgentContext context)
{
try
{
// 파일명 생성 — 동사/명령어를 제거하여 깔끔한 파일명 만들기
var title = userQuery.Length > Defaults.QueryTitleMaxLength ? userQuery[..Defaults.QueryTitleMaxLength] : userQuery;
// 파일명에 불필요한 동사/명령어 제거
var removeWords = new[] { "작성해줘", "작성해 줘", "만들어줘", "만들어 줘", "써줘", "써 줘",
"생성해줘", "생성해 줘", "작성해", "만들어", "생성해", "해줘", "해 줘", "부탁해" };
var safeTitle = title;
foreach (var w in removeWords)
safeTitle = safeTitle.Replace(w, "", StringComparison.OrdinalIgnoreCase);
foreach (var c in System.IO.Path.GetInvalidFileNameChars())
safeTitle = safeTitle.Replace(c, '_');
safeTitle = safeTitle.Trim().TrimEnd('.').Trim();
var fileName = $"{safeTitle}.html";
var fullPath = FileReadTool.ResolvePath(fileName, context.WorkFolder);
if (context.ActiveTab == "Cowork")
fullPath = AgentContext.EnsureTimestampedPath(fullPath);
var dir = System.IO.Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
// 텍스트 → HTML 변환
var css = TemplateService.GetCss("professional");
var htmlBody = ConvertTextToHtml(textContent);
var html = $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<title>{EscapeHtml(title)}</title>
<style>
{css}
.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}
.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}
.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}
.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}
.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}
.doc p {{ line-height: 1.8; margin-bottom: 12px; }}
.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}
.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}
.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}
.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}
.doc tr:nth-child(even) {{ background: #f8f9fa; }}
</style>
</head>
<body>
<div class=""doc"">
<h1>{EscapeHtml(title)}</h1>
<div class=""meta"">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>
{htmlBody}
</div>
</body>
</html>";
System.IO.File.WriteAllText(fullPath, html, System.Text.Encoding.UTF8);
LogService.Info($"[AgentLoop] 문서 자동 저장 완료: {fullPath}");
return fullPath;
}
catch (Exception ex)
{
LogService.Warn($"[AgentLoop] 문서 자동 저장 실패: {ex.Message}");
return null;
}
}
/// <summary>LLM 텍스트(마크다운 형식)를 HTML로 변환합니다.</summary>
private static string ConvertTextToHtml(string text)
{
var sb = new System.Text.StringBuilder();
var lines = text.Split('\n');
var inList = false;
var listType = "ul";
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd();
// 빈 줄
if (string.IsNullOrWhiteSpace(line))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
continue;
}
// 마크다운 제목
if (line.StartsWith("### "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[2..])}</h2>");
continue;
}
// 번호 리스트 (1. 2. 등) - 대제목급이면 h2로
if (System.Text.RegularExpressions.Regex.IsMatch(line, @"^\d+\.\s+\S"))
{
var content = System.Text.RegularExpressions.Regex.Replace(line, @"^\d+\.\s+", "");
// 짧고 제목 같으면 h2, 길면 리스트
if (content.Length < 80 && !content.Contains('.') && !line.StartsWith(" "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line)}</h2>");
}
else
{
if (!inList) { sb.AppendLine("<ol>"); inList = true; listType = "ol"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
}
continue;
}
// 불릿 리스트
if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• "))
{
var content = line.TrimStart()[2..].Trim();
if (!inList) { sb.AppendLine("<ul>"); inList = true; listType = "ul"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
continue;
}
// 일반 텍스트
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<p>{EscapeHtml(line)}</p>");
}
if (inList) sb.AppendLine($"</{listType}>");
return sb.ToString();
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>사용자 요청이 문서/보고서 생성인지 판단합니다.</summary>
private static bool IsDocumentCreationRequest(string query)
{
if (string.IsNullOrWhiteSpace(query)) return false;
// 문서 생성 관련 키워드 패턴
var keywords = new[]
{
"보고서", "리포트", "report", "문서", "작성해", "써줘", "써 줘", "만들어",
"분석서", "제안서", "기획서", "회의록", "매뉴얼", "가이드",
"excel", "엑셀", "docx", "word", "html", "pptx", "ppt",
"프레젠테이션", "발표자료", "슬라이드"
};
var q = query.ToLowerInvariant();
return keywords.Any(k => q.Contains(k, StringComparison.OrdinalIgnoreCase));
}
/// <summary>문서 생성 도구인지 확인합니다 (Cowork 검증 대상).</summary>
private static bool IsDocumentCreationTool(string toolName)
{
return toolName is "file_write" or "docx_create" or "html_create"
or "excel_create" or "csv_create" or "script_create" or "pptx_create";
}
/// <summary>
/// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다.
/// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다.
/// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다.
/// </summary>
private static bool IsTerminalDocumentTool(string toolName)
{
return toolName is "html_create" or "docx_create" or "excel_create"
or "pptx_create" or "document_assemble" or "csv_create";
}
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
private static bool IsCodeVerificationTarget(string toolName)
{
return toolName is "file_write" or "file_edit" or "script_create"
or "process"; // 빌드/테스트 실행 결과 검증
}
/// <summary>
/// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다.
/// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다.
/// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다.
/// </summary>
/// <summary>읽기 전용 검증 도구 목록 (file_read만 허용)</summary>
private static readonly HashSet<string> VerificationAllowedTools = ["file_read", "directory_list"];
private async Task RunPostToolVerificationAsync(
List<ChatMessage> messages, string toolName, ToolResult result,
AgentContext context, CancellationToken ct)
{
EmitEvent(AgentEventType.Thinking, "", "🔍 생성 결과물 검증 중...");
// 생성된 파일 경로 추출
var filePath = result.FilePath ?? "";
var fileRef = string.IsNullOrEmpty(filePath) ? "방금 생성한 결과물" : $"파일 '{filePath}'";
// 탭별 검증 프롬프트 생성 — 읽기 + 보고만 (수정 금지)
var isCodeTab = context.ActiveTab == "Code";
var checkList = isCodeTab
? " - 구문 오류가 없는가?\n" +
" - 참조하는 클래스/메서드/변수가 존재하는가?\n" +
" - 코딩 컨벤션이 일관적인가?\n" +
" - 에지 케이스 처리가 누락되지 않았는가?"
: " - 사용자 요청에 맞는 내용이 모두 포함되었는가?\n" +
" - 구조와 형식이 올바른가?\n" +
" - 누락된 섹션이나 불완전한 내용이 없는가?\n" +
" - 한국어 맞춤법/표현이 자연스러운가?";
var verificationPrompt = new ChatMessage
{
Role = "user",
Content = $"[System:Verification] {fileRef}을 검증하세요.\n" +
"1. file_read 도구로 생성된 파일의 내용을 읽으세요.\n" +
"2. 다음 항목을 확인하세요:\n" +
checkList + "\n" +
"3. 결과를 간단히 보고하세요. 문제가 있으면 구체적으로 무엇이 잘못되었는지 설명하세요.\n" +
"⚠️ 중요: 이 단계에서는 파일을 직접 수정하지 마세요. 보고만 하세요."
};
// 검증 메시지를 임시로 추가 (검증 완료 후 전부 제거)
var insertIndex = messages.Count;
messages.Add(verificationPrompt);
var addedMessages = new List<ChatMessage> { verificationPrompt };
try
{
// 읽기 전용 도구만 제공 (file_write, file_edit 등 쓰기 도구 차단)
var allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools);
var readOnlyTools = allTools
.Where(t => VerificationAllowedTools.Contains(t.Name))
.ToList();
var verifyBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct);
// 검증 응답 처리
var verifyText = new List<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>();
foreach (var block in verifyBlocks)
{
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
verifyText.Add(block.Text);
else if (block.Type == "tool_use")
verifyToolCalls.Add(block);
}
var verifyResponse = string.Join("\n", verifyText);
// file_read 도구 호출 처리 (읽기만 허용)
if (verifyToolCalls.Count > 0)
{
var contentBlocks = new List<object>();
if (!string.IsNullOrEmpty(verifyResponse))
contentBlocks.Add(new { type = "text", text = verifyResponse });
foreach (var tc in verifyToolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
messages.Add(assistantMsg);
addedMessages.Add(assistantMsg);
foreach (var tc in verifyToolCalls)
{
var tool = _tools.Get(tc.ToolName);
if (tool == null)
{
var errMsg = LlmService.CreateToolResultMessage(tc.ToolId, tc.ToolName, "검증 단계에서는 읽기 도구만 사용 가능합니다.");
messages.Add(errMsg);
addedMessages.Add(errMsg);
continue;
}
EmitEvent(AgentEventType.ToolCall, tc.ToolName, $"[검증] {FormatToolCallSummary(tc)}");
try
{
var input = tc.ToolInput ?? System.Text.Json.JsonDocument.Parse("{}").RootElement;
var verifyResult = await tool.ExecuteAsync(input, context, ct);
var toolMsg = LlmService.CreateToolResultMessage(
tc.ToolId, tc.ToolName, TruncateOutput(verifyResult.Output, Defaults.ToolResultTruncateLength));
messages.Add(toolMsg);
addedMessages.Add(toolMsg);
}
catch (Exception ex)
{
var errMsg = LlmService.CreateToolResultMessage(
tc.ToolId, tc.ToolName, $"검증 도구 실행 오류: {ex.Message}");
messages.Add(errMsg);
addedMessages.Add(errMsg);
}
}
// file_read 결과를 받은 후 최종 검증 판단을 받기 위해 한 번 더 호출
var finalBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct);
verifyResponse = string.Join("\n",
finalBlocks.Where(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text)).Select(b => b.Text));
}
// 검증 결과를 이벤트로 표시
if (!string.IsNullOrEmpty(verifyResponse))
{
var summary = verifyResponse.Length > Defaults.VerificationSummaryMaxLength ? verifyResponse[..Defaults.VerificationSummaryMaxLength] + "…" : verifyResponse;
EmitEvent(AgentEventType.Thinking, "", $"✅ 검증 결과: {summary}");
// 문제가 발견된 경우: 검증 보고서를 컨텍스트에 남겨서 다음 루프에서 자연스럽게 수정
var hasIssues = verifyResponse.Contains("문제") || verifyResponse.Contains("수정") ||
verifyResponse.Contains("누락") || verifyResponse.Contains("오류") ||
verifyResponse.Contains("잘못") || verifyResponse.Contains("부족");
if (hasIssues)
{
// 검증 관련 임시 메시지를 모두 제거
foreach (var msg in addedMessages)
messages.Remove(msg);
// 검증 보고서만 간결하게 남기기 (다음 루프에서 LLM이 자연스럽게 수정)
messages.Add(new ChatMessage
{
Role = "user",
Content = $"[System] 방금 생성한 {fileRef}에 대한 자동 검증 결과, 다음 문제가 발견되었습니다:\n{verifyResponse}\n\n위 문제를 수정해 주세요."
});
return;
}
}
}
catch (Exception ex)
{
EmitEvent(AgentEventType.Error, "", $"검증 LLM 호출 실패: {ex.Message}");
}
// 검증 통과 또는 실패: 임시 메시지 전부 제거 (컨텍스트 오염 방지)
foreach (var msg in addedMessages)
messages.Remove(msg);
}
private AgentContext BuildContext(string? tabOverride = null)
{
var llm = _settings.Settings.Llm;
@@ -1186,149 +843,4 @@ public partial class AgentLoopService
DevModeStepApproval = llm.DevModeStepApproval,
};
}
private void EmitEvent(AgentEventType type, string toolName, string summary,
string? filePath = null, int stepCurrent = 0, int stepTotal = 0, List<string>? steps = null,
long elapsedMs = 0, int inputTokens = 0, int outputTokens = 0,
string? toolInput = null, int iteration = 0)
{
// AgentLogLevel에 따라 이벤트 필터링
var logLevel = _settings.Settings.Llm.AgentLogLevel;
// simple: ToolCall, ToolResult, Error, Complete, StepStart, StepDone, Decision만
if (logLevel == "simple" && type is AgentEventType.Thinking or AgentEventType.Planning)
return;
// simple: Summary 200자 제한
if (logLevel == "simple" && summary.Length > 200)
summary = summary[..200] + "…";
// debug 아닌 경우 ToolInput 제거
if (logLevel != "debug")
toolInput = null;
var evt = new AgentEvent
{
Type = type,
ToolName = toolName,
Summary = summary,
FilePath = filePath,
Success = type != AgentEventType.Error,
StepCurrent = stepCurrent,
StepTotal = stepTotal,
Steps = steps,
ElapsedMs = elapsedMs,
InputTokens = inputTokens,
OutputTokens = outputTokens,
ToolInput = toolInput,
Iteration = iteration,
};
if (Dispatcher != null)
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
else
{
Events.Add(evt);
EventOccurred?.Invoke(evt);
}
}
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
{
var level = _settings.Settings.Llm.AgentDecisionLevel ?? "normal";
var toolName = call.ToolName ?? "";
var input = call.ToolInput;
// Git 커밋 — 수준에 관계없이 무조건 확인
if (toolName == "git_tool")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
if (action == "commit")
{
var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : "";
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
}
}
// minimal: 파일 삭제, 외부 명령만
if (level == "minimal")
{
// process 도구 (외부 명령 실행)
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
return null;
}
// normal: + 새 파일 생성, 여러 파일 수정, 문서 생성, 외부 명령
if (level == "normal" || level == "detailed")
{
// 외부 명령 실행
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
// 새 파일 생성
if (toolName == "file_write")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
if (!string.IsNullOrEmpty(path))
{
var fullPath = System.IO.Path.IsPathRooted(path) ? path
: System.IO.Path.Combine(context.WorkFolder, path ?? "");
if (!System.IO.File.Exists(fullPath))
return $"새 파일을 생성하시겠습니까?\n\n경로: {path}";
}
}
// 문서 생성 (Excel, Word, HTML 등)
if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
// 빌드/테스트 실행
if (toolName is "build_run" or "test_loop")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
}
}
// detailed: 모든 파일 수정
if (level == "detailed")
{
if (toolName is "file_write" or "file_edit")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
}
}
return null;
}
private static string FormatToolCallSummary(LlmService.ContentBlock call)
{
if (call.ToolInput == null) return call.ToolName;
try
{
// 주요 파라미터만 표시
var input = call.ToolInput.Value;
if (input.TryGetProperty("path", out var path))
return $"{call.ToolName}: {path.GetString()}";
if (input.TryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.GetString()}";
if (input.TryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.GetString()}";
return call.ToolName;
}
catch (Exception) { return call.ToolName; }
}
}