using System; namespace AxCopilot.Services.Agent; internal enum TranscriptRowKind { AssistantText, Thinking, ToolActivity, Permission, ToolResult, CompactBoundary, Waiting, PlanApproval, Status, } internal sealed record AgentTranscriptRowPresentation( TranscriptRowKind Kind, string BadgeLabel, string Title, string Description, string GroupKey, bool CanGroup, bool Emphasize); internal static class AgentTranscriptDisplayCatalog { public static string GetDisplayName(string? rawName, bool slashPrefix = false) { if (string.IsNullOrWhiteSpace(rawName)) return slashPrefix ? "/skill" : "도구"; var normalized = rawName.Trim(); var lowered = normalized.ToLowerInvariant(); var mapped = lowered switch { "file_read" => "파일 읽기", "file_write" => "파일 쓰기", "file_edit" => "파일 수정", "file_watch" => "파일 감시", "file_info" => "파일 정보", "file_manage" => "파일 관리", "glob" => "파일 찾기", "grep" => "내용 검색", "folder_map" => "폴더 구조", "multi_read" => "다중 파일 읽기", "document_read" or "document_reader" => "문서 읽기", "document_plan" or "document_planner" => "문서 계획", "document_assemble" or "document_assembler" => "문서 조합", "document_review" => "문서 검토", "format_convert" => "형식 변환", "template_render" => "템플릿 렌더", "html_create" => "HTML 생성", "docx_create" => "Word 생성", "markdown_create" or "md_create" => "Markdown 생성", "excel_create" or "xlsx_create" => "Excel 생성", "csv_create" => "CSV 생성", "pptx_create" => "PowerPoint 생성", "build_run" => "빌드/실행", "test_loop" => "테스트 루프", "dev_env_detect" => "개발 환경 감지", "git_tool" => "Git", "diff_tool" => "Diff", "diff_preview" => "Diff 미리보기", "process" => "명령 실행", "bash" => "Bash", "powershell" => "PowerShell", "web_fetch" => "웹 요청", "http" => "HTTP 요청", "user_ask" => "질문 요청", "suggest_actions" => "다음 작업 제안", "task_create" => "작업 생성", "task_update" => "작업 갱신", "task_list" => "작업 목록", "task_get" => "작업 조회", "task_stop" => "작업 중지", "task_output" => "작업 출력", "spawn_agent" => "서브에이전트", "spawn_agents" => "배치 에이전트", "wait_agents" => "에이전트 대기", _ => normalized.Replace('_', ' ').Trim(), }; if (!slashPrefix) return mapped; if (normalized.StartsWith('/')) return normalized; return "/" + lowered.Replace('_', '-').Replace(' ', '-'); } public static string GetEventBadgeLabel(AgentEvent evt) { if (evt.Type == AgentEventType.SkillCall) return "스킬"; if (evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied) return "권한"; return GetToolCategoryLabel(evt.ToolName); } public static string GetTaskCategoryLabel(string? kind, string? title) { if (string.Equals(kind, "permission", StringComparison.OrdinalIgnoreCase)) return "권한"; if (string.Equals(kind, "queue", StringComparison.OrdinalIgnoreCase)) return "대기열"; if (string.Equals(kind, "hook", StringComparison.OrdinalIgnoreCase)) return "훅"; if (string.Equals(kind, "subagent", StringComparison.OrdinalIgnoreCase)) return "에이전트"; if (string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase)) return GetToolCategoryLabel(title); return "작업"; } public static string BuildEventSummary(AgentEvent evt, string displayName) { var summary = (evt.Summary ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(summary)) return StripNonBmpCharacters(summary); return evt.Type switch { AgentEventType.ToolCall => $"{displayName} 실행 준비", AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패", AgentEventType.SkillCall => $"{displayName} 실행", AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.", AgentEventType.PermissionGranted => $"{displayName} 실행 권한을 확인했습니다.", AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.", AgentEventType.Complete => "에이전트 작업이 완료되었습니다.", AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.", _ => summary, }; } public static AgentTranscriptRowPresentation ResolveRowPresentation(AgentEvent evt, string itemDisplayName, string transcriptBadgeLabel) { var summary = (evt.Summary ?? string.Empty).Trim(); var toolName = (evt.ToolName ?? string.Empty).Trim().ToLowerInvariant(); var resultPresentation = evt.Type == AgentEventType.ToolResult ? ToolResultPresentationCatalog.Resolve(evt, transcriptBadgeLabel) : null; var permissionPresentation = evt.Type switch { AgentEventType.PermissionRequest => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: true), AgentEventType.PermissionGranted => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: false), _ => null }; if (string.Equals(toolName, "agent_wait", StringComparison.OrdinalIgnoreCase)) { return new AgentTranscriptRowPresentation( TranscriptRowKind.Waiting, "대기", "처리 중...", string.IsNullOrWhiteSpace(summary) ? "작업을 계속 진행하기 위한 응답을 기다리는 중입니다." : summary, "waiting:agent", true, true); } if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) { return new AgentTranscriptRowPresentation( TranscriptRowKind.CompactBoundary, "압축", "컨텍스트 압축 중...", string.IsNullOrWhiteSpace(summary) ? "긴 대화를 계속 진행하기 위해 컨텍스트를 정리하고 있습니다." : summary, "compact:context", true, true); } if (evt.Type == AgentEventType.Planning) { var title = evt.Steps is { Count: > 0 } ? $"계획 {evt.Steps.Count}단계 정리" : "작업 계획 정리 중"; return new AgentTranscriptRowPresentation( TranscriptRowKind.Thinking, "계획", title, string.IsNullOrWhiteSpace(summary) ? "실행 순서와 필요한 도구를 정리하고 있습니다." : summary, "planning", true, false); } if (evt.Type == AgentEventType.Decision) { return new AgentTranscriptRowPresentation( TranscriptRowKind.PlanApproval, "확인", "계획 확인 필요", string.IsNullOrWhiteSpace(summary) ? "사용자 확인이 필요한 계획 단계입니다." : summary, "plan:approval", false, true); } if (evt.Type is AgentEventType.StepStart or AgentEventType.StepDone) { var title = evt.StepTotal > 0 ? evt.Type == AgentEventType.StepStart ? $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행" : $"{evt.StepCurrent}/{evt.StepTotal} 단계 완료" : evt.Type == AgentEventType.StepStart ? "단계 진행" : "단계 완료"; return new AgentTranscriptRowPresentation( TranscriptRowKind.ToolActivity, "단계", title, string.IsNullOrWhiteSpace(summary) ? title : summary, $"step:{evt.StepCurrent}:{evt.StepTotal}:{evt.Type}", false, false); } if (evt.Type == AgentEventType.Thinking) { var title = ResolveThinkingTitle(summary, toolName); return new AgentTranscriptRowPresentation( TranscriptRowKind.Thinking, "생각", title, string.IsNullOrWhiteSpace(summary) ? title : summary, $"thinking:{ResolveActivityGroup(toolName, summary)}", true, false); } if (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.SkillCall) { var group = ResolveActivityGroup(toolName, summary); var title = BuildActivityTitle(toolName, itemDisplayName, summary); var badge = evt.Type == AgentEventType.SkillCall ? "스킬" : "도구"; return new AgentTranscriptRowPresentation( TranscriptRowKind.ToolActivity, badge, title, BuildActivityDescription(group, summary), $"activity:{group}", true, false); } if (permissionPresentation != null) { return new AgentTranscriptRowPresentation( TranscriptRowKind.Permission, "권한", permissionPresentation.Label, permissionPresentation.Description, $"permission:{permissionPresentation.Kind}", false, true); } if (resultPresentation != null) { // ToolResult의 GroupKey를 선행 ToolCall과 동일한 activity 그룹으로 설정 // → ProcessFeed에서 ToolCall 카드를 ToolResult로 교체(머지) var resultGroup = ResolveActivityGroup(toolName, summary); return new AgentTranscriptRowPresentation( TranscriptRowKind.ToolResult, "결과", resultPresentation.Label, resultPresentation.Description, $"activity:{resultGroup}", true, resultPresentation.NeedsAttention); } if (evt.Type == AgentEventType.Error) { return new AgentTranscriptRowPresentation( TranscriptRowKind.Status, "오류", "실행 중 오류 발생", string.IsNullOrWhiteSpace(summary) ? "에이전트 실행 중 오류가 발생했습니다." : summary, "status:error", false, true); } if (evt.Type == AgentEventType.Complete) { return new AgentTranscriptRowPresentation( TranscriptRowKind.Status, "완료", "작업 완료", string.IsNullOrWhiteSpace(summary) ? "에이전트 작업이 완료되었습니다." : summary, "status:complete", false, false); } return new AgentTranscriptRowPresentation( TranscriptRowKind.Status, transcriptBadgeLabel, string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary, string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary, $"status:{evt.Type}", false, false); } private static string ResolveThinkingTitle(string summary, string toolName) { if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase)) return "결과 검증 중..."; if (summary.Contains("diff", StringComparison.OrdinalIgnoreCase)) return "변경 내용 확인 중..."; if (summary.Contains("retry", StringComparison.OrdinalIgnoreCase) || summary.Contains("재시도", StringComparison.OrdinalIgnoreCase)) return "재시도 준비 중..."; if (summary.Contains("fallback", StringComparison.OrdinalIgnoreCase) || summary.Contains("자동 생성", StringComparison.OrdinalIgnoreCase)) return "대체 경로 준비 중..."; if (toolName.Contains("document")) return "문서 흐름 정리 중..."; return string.IsNullOrWhiteSpace(summary) ? "분석 중..." : summary; } private static string ResolveActivityGroup(string toolName, string summary) { if (toolName is "file_read" or "document_read" or "glob" or "grep" or "folder_map" or "multi_read") return "read"; if (toolName is "file_edit" or "file_write") return "edit"; if (toolName.Contains("build") || toolName.Contains("test") || toolName == "process") return "execute"; if (toolName.Contains("git") || toolName.Contains("diff")) return "git"; if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create") || toolName.Contains("markdown_create")) return "document"; if (toolName.Contains("web") || toolName.Contains("http")) return "web"; if (summary.Contains("권한", StringComparison.OrdinalIgnoreCase)) return "permission"; return "general"; } private static string BuildActivityTitle(string toolName, string itemDisplayName, string summary) { if (toolName is "multi_read") return "여러 파일 읽는 중"; if (toolName is "file_read" or "document_read") return "파일 읽는 중"; if (toolName is "glob") return "관련 파일 찾는 중"; if (toolName is "grep") return "코드 검색 중"; if (toolName is "folder_map") return "구조 확인 중"; if (toolName is "file_edit") return "파일 수정 중"; if (toolName is "file_write") return "파일 작성 중"; if (toolName.Contains("build") || toolName.Contains("test")) return "빌드/테스트 실행 중"; if (toolName == "process" || toolName == "bash" || toolName == "powershell") return "명령 실행 중"; if (toolName.Contains("git") || toolName.Contains("diff")) return "Git 작업 중"; if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create")) return "문서 결과 만드는 중"; if (toolName.Contains("web") || toolName.Contains("http")) return "웹 정보 확인 중"; if (!string.IsNullOrWhiteSpace(itemDisplayName)) return $"{itemDisplayName} 실행 중"; return string.IsNullOrWhiteSpace(summary) ? "도구 실행 중" : summary; } private static string BuildActivityDescription(string group, string summary) { if (!string.IsNullOrWhiteSpace(summary)) return summary; return group switch { "read" => "질문과 관련된 파일과 내용만 추려서 확인하고 있습니다.", "edit" => "필요한 변경만 적용하고 결과를 다시 확인하고 있습니다.", "execute" => "실행 결과와 로그를 확인해 다음 단계를 판단하고 있습니다.", "git" => "변경 범위와 저장소 상태를 확인하고 있습니다.", "document" => "문서 산출물을 준비하고 있습니다.", "web" => "필요한 외부 정보를 확인하고 있습니다.", _ => "다음 단계를 진행하기 위한 작업을 실행하고 있습니다." }; } private static string GetToolCategoryLabel(string? rawName) { if (string.IsNullOrWhiteSpace(rawName)) return "도구"; return rawName.Trim().ToLowerInvariant() switch { "file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage" or "multi_read" => "파일", "build_run" or "test_loop" or "dev_env_detect" => "빌드", "git_tool" or "diff_tool" or "diff_preview" => "Git", "document_read" or "document_reader" or "document_plan" or "document_planner" or "document_assemble" or "document_assembler" or "document_review" or "format_convert" or "template_render" or "html_create" or "docx_create" => "문서", "user_ask" => "질문", "suggest_actions" => "제안", "process" or "bash" or "powershell" => "명령", "spawn_agent" or "spawn_agents" or "wait_agents" => "에이전트", "web_fetch" or "http" => "웹", _ => "도구", }; } /// /// WPF 기본 폰트(Segoe UI)에서 렌더링되지 않는 비-BMP 유니코드 문자(이모지 등)를 제거합니다. /// LLM 응답에 이모지가 포함되면 깨져서 표시되는 문제를 방지합니다. /// public static string StripNonBmpCharacters(string text) { if (string.IsNullOrEmpty(text)) return string.Empty; // Consolas / Segoe UI에서 렌더링 불가한 비-BMP 유니코드(이모지, 서로게이트 쌍) 제거 var sb = new System.Text.StringBuilder(text.Length); for (int i = 0; i < text.Length; i++) { var c = text[i]; if (char.IsHighSurrogate(c)) { if (i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) i++; continue; } if (char.IsLowSurrogate(c)) continue; if (c >= 0x2600 && c <= 0x27BF) continue; // Misc symbols, Dingbats if (c >= 0x2B50 && c <= 0x2B55) continue; // Additional symbols if (c >= 0xFE00 && c <= 0xFE0F) continue; // Variation selectors sb.Append(c); } return sb.ToString().Trim(); } }