diff --git a/src/AxCopilot/Services/Agent/SkillDefinition.cs b/src/AxCopilot/Services/Agent/SkillDefinition.cs new file mode 100644 index 0000000..a8bee75 --- /dev/null +++ b/src/AxCopilot/Services/Agent/SkillDefinition.cs @@ -0,0 +1,81 @@ +namespace AxCopilot.Services.Agent; + +/// 스킬 정의 (*.skill.md에서 로드). +public class SkillDefinition +{ + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public string Icon { get; init; } = "\uE768"; + public string SystemPrompt { get; init; } = ""; + public string FilePath { get; init; } = ""; + + // SKILL.md 표준 확장 필드 + public string License { get; init; } = ""; + public string Compatibility { get; init; } = ""; + public string AllowedTools { get; init; } = ""; + + /// 런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음. + public string Requires { get; init; } = ""; + + /// 표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능. + public string Tabs { get; init; } = "all"; + + // ── Phase 24: CC 고급 프론트매터 필드 ── + + /// 실행 컨텍스트. "fork"이면 격리된 서브에이전트에서 실행. + public string Context { get; init; } = ""; + + /// 이 스킬에 사용할 모델 오버라이드. 빈 문자열이면 기본 모델 사용. + public string ModelOverride { get; init; } = ""; + + /// 사용자가 /로 호출 가능한지 여부. false면 AI만 사용. + public bool UserInvocable { get; init; } = true; + + /// 자동 활성화 경로 패턴. "**/*.py" 등 — 매칭 파일 터치 시 스킬 자동 제안. + public string Paths { get; init; } = ""; + + /// 스킬 실행 중에만 활성화되는 스코프 훅 정의 (JSON). + public string ScopedHooks { get; init; } = ""; + + /// 프론트매터 arguments 필드. 명명된 인수 목록. + public IReadOnlyList Arguments { get; init; } = Array.Empty(); + + /// 자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트. + public string WhenToUse { get; init; } = ""; + + /// 스킬 버전. + public string Version { get; init; } = ""; + + /// 런타임 의존성 충족 여부. Requires가 비어있으면 항상 true. + public bool IsAvailable { get; set; } = true; + + /// context:fork 설정인지 여부. + public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase); + + /// 지정 탭에서 이 스킬을 표시할지 판정합니다. + public bool IsVisibleInTab(string activeTab) + { + if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase)) + return true; + var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant()); + var tab = activeTab.ToLowerInvariant(); + return tabs.Any(t => t == "all" || t == tab); + } + + /// SKILL.md 표준 폴더 형식인지 여부. + public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase); + + /// 비가용 시 사용자에게 표시할 힌트 메시지. + public string UnavailableHint + { + get + { + if (IsAvailable || string.IsNullOrEmpty(Requires)) return ""; + var runtimes = Requires.Split(',').Select(r => r.Trim()); + var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray(); + return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : ""; + } + } +} diff --git a/src/AxCopilot/Services/Agent/SkillService.Import.cs b/src/AxCopilot/Services/Agent/SkillService.Import.cs new file mode 100644 index 0000000..ed8fb4e --- /dev/null +++ b/src/AxCopilot/Services/Agent/SkillService.Import.cs @@ -0,0 +1,203 @@ +using System.IO; +using System.IO.Compression; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +public static partial class SkillService +{ + // ─── 가져오기/내보내기 ────────────────────────────────────────────────── + + /// + /// 스킬을 zip 파일로 내보냅니다. + /// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더). + /// + /// 생성된 zip 파일 경로. 실패 시 null. + public static string? ExportSkill(SkillDefinition skill, string outputDir) + { + try + { + if (!File.Exists(skill.FilePath)) + { + LogService.Warn($"스킬 내보내기 실패: 파일 없음 — {skill.FilePath}"); + return null; + } + + var zipName = $"{skill.Name}.skill.zip"; + var zipPath = Path.Combine(outputDir, zipName); + + // 기존 파일이 있으면 삭제 + if (File.Exists(zipPath)) File.Delete(zipPath); + + using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); + + if (skill.IsStandardFormat) + { + // SKILL.md 표준: 전체 폴더를 zip에 추가 + var skillDir = Path.GetDirectoryName(skill.FilePath); + if (skillDir != null && Directory.Exists(skillDir)) + { + var baseName = Path.GetFileName(skillDir); + foreach (var file in Directory.EnumerateFiles(skillDir, "*", SearchOption.AllDirectories)) + { + // 실행 가능 파일 제외 + var ext = Path.GetExtension(file).ToLowerInvariant(); + if (ext is ".exe" or ".dll" or ".bat" or ".cmd" or ".ps1" or ".sh") continue; + + var entryName = baseName + "/" + Path.GetRelativePath(skillDir, file).Replace('\\', '/'); + zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal); + } + } + } + else + { + // *.skill.md 파일 단독 + var entryName = $"{skill.Name}/{Path.GetFileName(skill.FilePath)}"; + zip.CreateEntryFromFile(skill.FilePath, entryName, CompressionLevel.Optimal); + } + + LogService.Info($"스킬 내보내기 완료: {zipPath}"); + return zipPath; + } + catch (Exception ex) + { + LogService.Warn($"스킬 내보내기 실패: {ex.Message}"); + return null; + } + } + + /// + /// zip 파일에서 스킬을 가져옵니다. + /// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다. + /// + /// 가져온 스킬 수. 0이면 실패. + public static int ImportSkills(string zipPath) + { + try + { + if (!File.Exists(zipPath)) + { + LogService.Warn($"스킬 가져오기 실패: 파일 없음 — {zipPath}"); + return 0; + } + + var userFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "skills"); + if (!Directory.Exists(userFolder)) + Directory.CreateDirectory(userFolder); + + using var zip = ZipFile.OpenRead(zipPath); + + // 보안 검증: 실행 가능 파일 차단 + var dangerousExts = new HashSet(StringComparer.OrdinalIgnoreCase) + { ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" }; + foreach (var entry in zip.Entries) + { + if (dangerousExts.Contains(Path.GetExtension(entry.Name))) + { + LogService.Warn($"스킬 가져오기 차단: 실행 가능 파일 포함 — {entry.FullName}"); + return 0; + } + } + + // 스킬 파일 존재 여부 확인 + var skillEntries = zip.Entries + .Where(e => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) + || e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (skillEntries.Count == 0) + { + LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음"); + return 0; + } + + // zip 압축 해제 + int importedCount = 0; + foreach (var entry in zip.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // 디렉토리 항목 건너뛰기 + + // 상위 경로 이탈 방지 + var relativePath = entry.FullName.Replace('/', Path.DirectorySeparatorChar); + if (relativePath.Contains("..")) continue; + + var destPath = Path.Combine(userFolder, relativePath); + var destDir = Path.GetDirectoryName(destPath); + if (destDir != null && !Directory.Exists(destDir)) + Directory.CreateDirectory(destDir); + + entry.ExtractToFile(destPath, overwrite: true); + + if (entry.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) + || entry.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) + importedCount++; + } + + if (importedCount > 0) + { + LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})"); + // 스킬 목록 리로드 + LoadSkills(); + } + + return importedCount; + } + catch (Exception ex) + { + LogService.Warn($"스킬 가져오기 실패: {ex.Message}"); + return 0; + } + } + + // ─── 도구 이름 매핑 (외부 스킬 호환) ────────────────────────────────────── + + /// + /// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다. + /// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다. + /// + private static readonly Dictionary ToolNameMap = new(StringComparer.OrdinalIgnoreCase) + { + // Claude Code / Cursor 표준 + ["Bash"] = "process", + ["bash"] = "process", + ["Read"] = "file_read", + ["Write"] = "file_write", + ["Edit"] = "file_edit", + ["Glob"] = "glob", + ["Grep"] = "grep_tool", + ["WebSearch"] = "http_tool", + ["WebFetch"] = "http_tool", + // agentskills.io 표준 + ["execute_command"] = "process", + ["read_file"] = "file_read", + ["write_file"] = "file_write", + ["edit_file"] = "file_edit", + ["search_files"] = "glob", + ["search_content"] = "grep_tool", + ["list_files"] = "folder_map", + // 기타 일반적 도구명 + ["shell"] = "process", + ["terminal"] = "process", + ["cat"] = "file_read", + ["find"] = "glob", + ["rg"] = "grep_tool", + ["git"] = "git_tool", + }; + + /// 스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다. + public static string MapToolNames(string skillBody) + { + if (string.IsNullOrEmpty(skillBody)) return skillBody; + + foreach (var kv in ToolNameMap) + { + // 코드 블록 내 도구 참조: `Bash`, `Read` 등을 `process`, `file_read`로 변환 + skillBody = skillBody.Replace($"`{kv.Key}`", $"`{kv.Value}`"); + // 괄호 내 참조: (Bash), (Read) 패턴 + skillBody = skillBody.Replace($"({kv.Key})", $"({kv.Value})"); + } + + return skillBody; + } +} diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index 96e7e5b..056a5f5 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -1,5 +1,4 @@ using System.IO; -using System.IO.Compression; using System.Text; using AxCopilot.Services; @@ -10,7 +9,7 @@ namespace AxCopilot.Services.Agent; /// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다. /// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다. /// -public static class SkillService +public static partial class SkillService { private static List _skills = new(); private static string _lastFolder = ""; @@ -203,201 +202,6 @@ public static class SkillService """); } - // ─── 가져오기/내보내기 ────────────────────────────────────────────────── - - /// - /// 스킬을 zip 파일로 내보냅니다. - /// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더). - /// - /// 생성된 zip 파일 경로. 실패 시 null. - public static string? ExportSkill(SkillDefinition skill, string outputDir) - { - try - { - if (!File.Exists(skill.FilePath)) - { - LogService.Warn($"스킬 내보내기 실패: 파일 없음 — {skill.FilePath}"); - return null; - } - - var zipName = $"{skill.Name}.skill.zip"; - var zipPath = Path.Combine(outputDir, zipName); - - // 기존 파일이 있으면 삭제 - if (File.Exists(zipPath)) File.Delete(zipPath); - - using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); - - if (skill.IsStandardFormat) - { - // SKILL.md 표준: 전체 폴더를 zip에 추가 - var skillDir = Path.GetDirectoryName(skill.FilePath); - if (skillDir != null && Directory.Exists(skillDir)) - { - var baseName = Path.GetFileName(skillDir); - foreach (var file in Directory.EnumerateFiles(skillDir, "*", SearchOption.AllDirectories)) - { - // 실행 가능 파일 제외 - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (ext is ".exe" or ".dll" or ".bat" or ".cmd" or ".ps1" or ".sh") continue; - - var entryName = baseName + "/" + Path.GetRelativePath(skillDir, file).Replace('\\', '/'); - zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal); - } - } - } - else - { - // *.skill.md 파일 단독 - var entryName = $"{skill.Name}/{Path.GetFileName(skill.FilePath)}"; - zip.CreateEntryFromFile(skill.FilePath, entryName, CompressionLevel.Optimal); - } - - LogService.Info($"스킬 내보내기 완료: {zipPath}"); - return zipPath; - } - catch (Exception ex) - { - LogService.Warn($"스킬 내보내기 실패: {ex.Message}"); - return null; - } - } - - /// - /// zip 파일에서 스킬을 가져옵니다. - /// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다. - /// - /// 가져온 스킬 수. 0이면 실패. - public static int ImportSkills(string zipPath) - { - try - { - if (!File.Exists(zipPath)) - { - LogService.Warn($"스킬 가져오기 실패: 파일 없음 — {zipPath}"); - return 0; - } - - var userFolder = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AxCopilot", "skills"); - if (!Directory.Exists(userFolder)) - Directory.CreateDirectory(userFolder); - - using var zip = ZipFile.OpenRead(zipPath); - - // 보안 검증: 실행 가능 파일 차단 - var dangerousExts = new HashSet(StringComparer.OrdinalIgnoreCase) - { ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" }; - foreach (var entry in zip.Entries) - { - if (dangerousExts.Contains(Path.GetExtension(entry.Name))) - { - LogService.Warn($"스킬 가져오기 차단: 실행 가능 파일 포함 — {entry.FullName}"); - return 0; - } - } - - // 스킬 파일 존재 여부 확인 - var skillEntries = zip.Entries - .Where(e => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) - || e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (skillEntries.Count == 0) - { - LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음"); - return 0; - } - - // zip 압축 해제 - int importedCount = 0; - foreach (var entry in zip.Entries) - { - if (string.IsNullOrEmpty(entry.Name)) continue; // 디렉토리 항목 건너뛰기 - - // 상위 경로 이탈 방지 - var relativePath = entry.FullName.Replace('/', Path.DirectorySeparatorChar); - if (relativePath.Contains("..")) continue; - - var destPath = Path.Combine(userFolder, relativePath); - var destDir = Path.GetDirectoryName(destPath); - if (destDir != null && !Directory.Exists(destDir)) - Directory.CreateDirectory(destDir); - - entry.ExtractToFile(destPath, overwrite: true); - - if (entry.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) - || entry.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) - importedCount++; - } - - if (importedCount > 0) - { - LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})"); - // 스킬 목록 리로드 - LoadSkills(); - } - - return importedCount; - } - catch (Exception ex) - { - LogService.Warn($"스킬 가져오기 실패: {ex.Message}"); - return 0; - } - } - - // ─── 도구 이름 매핑 (외부 스킬 호환) ────────────────────────────────────── - - /// - /// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다. - /// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다. - /// - private static readonly Dictionary ToolNameMap = new(StringComparer.OrdinalIgnoreCase) - { - // Claude Code / Cursor 표준 - ["Bash"] = "process", - ["bash"] = "process", - ["Read"] = "file_read", - ["Write"] = "file_write", - ["Edit"] = "file_edit", - ["Glob"] = "glob", - ["Grep"] = "grep_tool", - ["WebSearch"] = "http_tool", - ["WebFetch"] = "http_tool", - // agentskills.io 표준 - ["execute_command"] = "process", - ["read_file"] = "file_read", - ["write_file"] = "file_write", - ["edit_file"] = "file_edit", - ["search_files"] = "glob", - ["search_content"] = "grep_tool", - ["list_files"] = "folder_map", - // 기타 일반적 도구명 - ["shell"] = "process", - ["terminal"] = "process", - ["cat"] = "file_read", - ["find"] = "glob", - ["rg"] = "grep_tool", - ["git"] = "git_tool", - }; - - /// 스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다. - public static string MapToolNames(string skillBody) - { - if (string.IsNullOrEmpty(skillBody)) return skillBody; - - foreach (var kv in ToolNameMap) - { - // 코드 블록 내 도구 참조: `Bash`, `Read` 등을 `process`, `file_read`로 변환 - skillBody = skillBody.Replace($"`{kv.Key}`", $"`{kv.Value}`"); - // 괄호 내 참조: (Bash), (Read) 패턴 - skillBody = skillBody.Replace($"({kv.Key})", $"({kv.Value})"); - } - - return skillBody; - } - // ─── 내부 메서드 ───────────────────────────────────────────────────────── /// @@ -580,82 +384,3 @@ public static class SkillService } } -/// 스킬 정의 (*.skill.md에서 로드). -public class SkillDefinition -{ - public string Id { get; init; } = ""; - public string Name { get; init; } = ""; - public string Label { get; init; } = ""; - public string Description { get; init; } = ""; - public string Icon { get; init; } = "\uE768"; - public string SystemPrompt { get; init; } = ""; - public string FilePath { get; init; } = ""; - - // SKILL.md 표준 확장 필드 - public string License { get; init; } = ""; - public string Compatibility { get; init; } = ""; - public string AllowedTools { get; init; } = ""; - - /// 런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음. - public string Requires { get; init; } = ""; - - /// 표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능. - public string Tabs { get; init; } = "all"; - - // ── Phase 24: CC 고급 프론트매터 필드 ── - - /// 실행 컨텍스트. "fork"이면 격리된 서브에이전트에서 실행. - public string Context { get; init; } = ""; - - /// 이 스킬에 사용할 모델 오버라이드. 빈 문자열이면 기본 모델 사용. - public string ModelOverride { get; init; } = ""; - - /// 사용자가 /로 호출 가능한지 여부. false면 AI만 사용. - public bool UserInvocable { get; init; } = true; - - /// 자동 활성화 경로 패턴. "**/*.py" 등 — 매칭 파일 터치 시 스킬 자동 제안. - public string Paths { get; init; } = ""; - - /// 스킬 실행 중에만 활성화되는 스코프 훅 정의 (JSON). - public string ScopedHooks { get; init; } = ""; - - /// 프론트매터 arguments 필드. 명명된 인수 목록. - public IReadOnlyList Arguments { get; init; } = Array.Empty(); - - /// 자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트. - public string WhenToUse { get; init; } = ""; - - /// 스킬 버전. - public string Version { get; init; } = ""; - - /// 런타임 의존성 충족 여부. Requires가 비어있으면 항상 true. - public bool IsAvailable { get; set; } = true; - - /// context:fork 설정인지 여부. - public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase); - - /// 지정 탭에서 이 스킬을 표시할지 판정합니다. - public bool IsVisibleInTab(string activeTab) - { - if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase)) - return true; - var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant()); - var tab = activeTab.ToLowerInvariant(); - return tabs.Any(t => t == "all" || t == tab); - } - - /// SKILL.md 표준 폴더 형식인지 여부. - public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase); - - /// 비가용 시 사용자에게 표시할 힌트 메시지. - public string UnavailableHint - { - get - { - if (IsAvailable || string.IsNullOrEmpty(Requires)) return ""; - var runtimes = Requires.Split(',').Select(r => r.Trim()); - var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray(); - return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : ""; - } - } -} diff --git a/src/AxCopilot/Views/ChatWindow.ConversationExport.cs b/src/AxCopilot/Views/ChatWindow.ConversationExport.cs new file mode 100644 index 0000000..8e1e753 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ConversationExport.cs @@ -0,0 +1,188 @@ +using System.Windows; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 대화 분기 (Fork) ────────────────────────────────────────────── + + private void ForkConversation(ChatConversation source, int atIndex) + { + var branchCount = _storage.LoadAllMeta() + .Count(m => m.ParentId == source.Id) + 1; + + var fork = new ChatConversation + { + Title = $"{source.Title} (분기 {branchCount})", + Tab = source.Tab, + Category = source.Category, + WorkFolder = source.WorkFolder, + SystemCommand = source.SystemCommand, + ParentId = source.Id, + BranchLabel = $"분기 {branchCount}", + BranchAtIndex = atIndex, + }; + + // 분기 시점까지의 메시지 복제 + for (int i = 0; i <= atIndex && i < source.Messages.Count; i++) + { + var m = source.Messages[i]; + fork.Messages.Add(new ChatMessage + { + Role = m.Role, + Content = m.Content, + Timestamp = m.Timestamp, + }); + } + + try + { + _storage.Save(fork); + ShowToast($"분기 생성: {fork.Title}"); + + // 분기 대화로 전환 + lock (_convLock) _currentConversation = fork; + ChatTitle.Text = fork.Title; + RenderMessages(); + RefreshConversationList(); + } + catch (Exception ex) + { + ShowToast($"분기 실패: {ex.Message}", "\uE783"); + } + } + + // ─── 커맨드 팔레트 ───────────────────────────────────────────────── + + private void OpenCommandPalette() + { + var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; + palette.ShowDialog(); + } + + private void ExecuteCommand(string commandId) + { + switch (commandId) + { + case "tab:chat": TabChat.IsChecked = true; break; + case "tab:cowork": TabCowork.IsChecked = true; break; + case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; + case "new_conversation": StartNewConversation(); break; + case "search_conversation": ToggleMessageSearch(); break; + case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; + case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; + case "open_statistics": new StatisticsWindow().Show(); break; + case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; + case "toggle_devmode": + var llm = Llm; + llm.DevMode = !llm.DevMode; + _settings.Save(); + UpdateAnalyzerButtonVisibility(); + ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); + break; + case "open_audit_log": + try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ } + break; + case "paste_clipboard": + try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ } + break; + case "export_conversation": ExportConversation(); break; + } + } + + private void ExportConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null || conv.Messages.Count == 0) return; + + var dlg = new Microsoft.Win32.SaveFileDialog + { + FileName = $"{conv.Title}", + DefaultExt = ".md", + Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" + }; + if (dlg.ShowDialog() != true) return; + + var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); + string content; + + if (ext == ".json") + { + content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + else if (dlg.FileName.EndsWith(".pdf.html")) + { + // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 + content = PdfExportService.BuildHtml(conv); + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + PdfExportService.OpenInBrowser(dlg.FileName); + ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); + return; + } + else if (ext == ".html") + { + content = ExportToHtml(conv); + } + else + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"# {conv.Title}"); + sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); + sb.AppendLine(); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var label = msg.Role == "user" ? "**사용자**" : "**AI**"; + sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); + sb.AppendLine(); + sb.AppendLine(msg.Content); + if (msg.AttachedFiles is { Count: > 0 }) + { + sb.AppendLine(); + sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); + } + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + } + content = sb.ToString(); + } + + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + } + + private static string ExportToHtml(ChatConversation conv) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(""); + sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); + sb.AppendLine(""); + sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); + sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var cls = msg.Role == "user" ? "user" : "ai"; + var label = msg.Role == "user" ? "사용자" : "AI"; + sb.AppendLine($"
"); + sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); + sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); + sb.AppendLine("
"); + } + + sb.AppendLine(""); + return sb.ToString(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs index 7920b87..ad6b0e7 100644 --- a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs +++ b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs @@ -459,251 +459,4 @@ public partial class ChatWindow System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}"); } } - - /// 프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다. - private Popup? _previewTabPopup; - - private void ShowPreviewTabContextMenu(string filePath) - { - // 기존 팝업 닫기 - if (_previewTabPopup != null) _previewTabPopup.IsOpen = false; - - var bg = ThemeResourceHelper.Background(this); - var borderBrush = ThemeResourceHelper.Border(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var hoverBg = ThemeResourceHelper.HoverBg(this); - - var stack = new StackPanel(); - - void AddItem(string icon, string iconColor, string label, Action action) - { - var itemBorder = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(10, 7, 16, 7), - Cursor = Cursors.Hand, - }; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor) - ? secondaryText - : ThemeResourceHelper.HexBrush(iconColor), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 13, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - itemBorder.Child = sp; - itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; - itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - itemBorder.MouseLeftButtonUp += (_, _) => - { - _previewTabPopup!.IsOpen = false; - action(); - }; - stack.Children.Add(itemBorder); - } - - void AddSeparator() - { - stack.Children.Add(new Border - { - Height = 1, - Background = borderBrush, - Margin = new Thickness(8, 3, 8, 3), - }); - } - - AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () => - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = filePath, UseShellExecute = true, - }); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - }); - - AddItem("\uE838", "#FFB74D", "파일 위치 열기", () => - { - try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - }); - - AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath)); - - AddSeparator(); - - AddItem("\uE8C8", "", "경로 복사", () => - { - try { Clipboard.SetText(filePath); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - AddSeparator(); - - AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath)); - - if (_previewTabs.Count > 1) - { - AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () => - { - var keep = filePath; - _previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase)); - _activePreviewTab = keep; - RebuildPreviewTabs(); - LoadPreviewContent(keep); - }); - } - - var popupBorder = new Border - { - Background = bg, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(12), - Padding = new Thickness(4, 6, 4, 6), - MinWidth = 180, - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4, - Color = Colors.Black, - }, - Child = stack, - }; - - _previewTabPopup = new Popup - { - Child = popupBorder, - Placement = PlacementMode.MousePoint, - StaysOpen = false, - AllowsTransparency = true, - PopupAnimation = PopupAnimation.Fade, - }; - _previewTabPopup.IsOpen = true; - } - - /// 프리뷰를 별도 팝업 창에서 엽니다. - private void OpenPreviewPopupWindow(string filePath) - { - if (!System.IO.File.Exists(filePath)) return; - - var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); - var fileName = System.IO.Path.GetFileName(filePath); - var bg = ThemeResourceHelper.Background(this); - var fg = ThemeResourceHelper.Primary(this); - - var win = new Window - { - Title = $"미리보기 — {fileName}", - Width = 900, - Height = 700, - WindowStartupLocation = WindowStartupLocation.CenterScreen, - Background = bg, - }; - - FrameworkElement content; - - switch (ext) - { - case ".html": - case ".htm": - var wv = new Microsoft.Web.WebView2.Wpf.WebView2(); - wv.Loaded += async (_, _) => - { - try - { - var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( - userDataFolder: WebView2DataFolder); - await wv.EnsureCoreWebView2Async(env); - wv.Source = new Uri(filePath); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - }; - content = wv; - break; - - case ".md": - var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2(); - var mdMood = _selectedMood; - mdWv.Loaded += async (_, _) => - { - try - { - var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( - userDataFolder: WebView2DataFolder); - await mdWv.EnsureCoreWebView2Async(env); - var mdSrc = System.IO.File.ReadAllText(filePath); - if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000]; - var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood); - mdWv.NavigateToString(html); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - }; - content = mdWv; - break; - - case ".csv": - var dg = new System.Windows.Controls.DataGrid - { - AutoGenerateColumns = true, - IsReadOnly = true, - Background = Brushes.Transparent, - Foreground = Brushes.White, - BorderThickness = new Thickness(0), - FontSize = 12, - }; - try - { - var lines = System.IO.File.ReadAllLines(filePath); - if (lines.Length > 0) - { - var dt = new System.Data.DataTable(); - var headers = ParseCsvLine(lines[0]); - foreach (var h in headers) dt.Columns.Add(h); - for (int i = 1; i < Math.Min(lines.Length, 1001); i++) - { - var vals = ParseCsvLine(lines[i]); - var row = dt.NewRow(); - for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++) - row[j] = vals[j]; - dt.Rows.Add(row); - } - dg.ItemsSource = dt.DefaultView; - } - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - content = dg; - break; - - default: - var text = System.IO.File.ReadAllText(filePath); - if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)"; - var sv = new ScrollViewer - { - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(20), - Content = new TextBlock - { - Text = text, - TextWrapping = TextWrapping.Wrap, - FontFamily = ThemeResourceHelper.Consolas, - FontSize = 13, - Foreground = fg, - }, - }; - content = sv; - break; - } - - win.Content = content; - win.Show(); - } } diff --git a/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs b/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs new file mode 100644 index 0000000..51f74c6 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs @@ -0,0 +1,259 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 미리보기 팝업 ───────────────────────────────────────────────────────── + + /// 프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다. + private Popup? _previewTabPopup; + + private void ShowPreviewTabContextMenu(string filePath) + { + // 기존 팝업 닫기 + if (_previewTabPopup != null) _previewTabPopup.IsOpen = false; + + var bg = ThemeResourceHelper.Background(this); + var borderBrush = ThemeResourceHelper.Border(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var hoverBg = ThemeResourceHelper.HoverBg(this); + + var stack = new StackPanel(); + + void AddItem(string icon, string iconColor, string label, Action action) + { + var itemBorder = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 7, 16, 7), + Cursor = Cursors.Hand, + }; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor) + ? secondaryText + : ThemeResourceHelper.HexBrush(iconColor), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + itemBorder.Child = sp; + itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + itemBorder.MouseLeftButtonUp += (_, _) => + { + _previewTabPopup!.IsOpen = false; + action(); + }; + stack.Children.Add(itemBorder); + } + + void AddSeparator() + { + stack.Children.Add(new Border + { + Height = 1, + Background = borderBrush, + Margin = new Thickness(8, 3, 8, 3), + }); + } + + AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = filePath, UseShellExecute = true, + }); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + }); + + AddItem("\uE838", "#FFB74D", "파일 위치 열기", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + }); + + AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath)); + + AddSeparator(); + + AddItem("\uE8C8", "", "경로 복사", () => + { + try { Clipboard.SetText(filePath); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + AddSeparator(); + + AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath)); + + if (_previewTabs.Count > 1) + { + AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () => + { + var keep = filePath; + _previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase)); + _activePreviewTab = keep; + RebuildPreviewTabs(); + LoadPreviewContent(keep); + }); + } + + var popupBorder = new Border + { + Background = bg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(4, 6, 4, 6), + MinWidth = 180, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4, + Color = Colors.Black, + }, + Child = stack, + }; + + _previewTabPopup = new Popup + { + Child = popupBorder, + Placement = PlacementMode.MousePoint, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + }; + _previewTabPopup.IsOpen = true; + } + + /// 프리뷰를 별도 팝업 창에서 엽니다. + private void OpenPreviewPopupWindow(string filePath) + { + if (!System.IO.File.Exists(filePath)) return; + + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + var fileName = System.IO.Path.GetFileName(filePath); + var bg = ThemeResourceHelper.Background(this); + var fg = ThemeResourceHelper.Primary(this); + + var win = new Window + { + Title = $"미리보기 — {fileName}", + Width = 900, + Height = 700, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Background = bg, + }; + + FrameworkElement content; + + switch (ext) + { + case ".html": + case ".htm": + var wv = new Microsoft.Web.WebView2.Wpf.WebView2(); + wv.Loaded += async (_, _) => + { + try + { + var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( + userDataFolder: WebView2DataFolder); + await wv.EnsureCoreWebView2Async(env); + wv.Source = new Uri(filePath); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + }; + content = wv; + break; + + case ".md": + var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2(); + var mdMood = _selectedMood; + mdWv.Loaded += async (_, _) => + { + try + { + var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( + userDataFolder: WebView2DataFolder); + await mdWv.EnsureCoreWebView2Async(env); + var mdSrc = System.IO.File.ReadAllText(filePath); + if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000]; + var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood); + mdWv.NavigateToString(html); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + }; + content = mdWv; + break; + + case ".csv": + var dg = new System.Windows.Controls.DataGrid + { + AutoGenerateColumns = true, + IsReadOnly = true, + Background = Brushes.Transparent, + Foreground = Brushes.White, + BorderThickness = new Thickness(0), + FontSize = 12, + }; + try + { + var lines = System.IO.File.ReadAllLines(filePath); + if (lines.Length > 0) + { + var dt = new System.Data.DataTable(); + var headers = ParseCsvLine(lines[0]); + foreach (var h in headers) dt.Columns.Add(h); + for (int i = 1; i < Math.Min(lines.Length, 1001); i++) + { + var vals = ParseCsvLine(lines[i]); + var row = dt.NewRow(); + for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++) + row[j] = vals[j]; + dt.Rows.Add(row); + } + dg.ItemsSource = dt.DefaultView; + } + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + content = dg; + break; + + default: + var text = System.IO.File.ReadAllText(filePath); + if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)"; + var sv = new ScrollViewer + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(20), + Content = new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + FontFamily = ThemeResourceHelper.Consolas, + FontSize = 13, + Foreground = fg, + }, + }; + content = sv; + break; + } + + win.Content = content; + win.Show(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs index f784e8b..7cc583f 100644 --- a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs +++ b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs @@ -1,8 +1,7 @@ -using System.Windows; +using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Animation; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; @@ -253,7 +252,7 @@ public partial class ChatWindow FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); AutoScrollIfNeeded(); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + try { _storage.Save(conv); } catch (Exception ex) { LogService.Debug($"대화 저장 실패: {ex.Message}"); } _tabConversationId[conv.Tab ?? _activeTab] = conv.Id; RefreshConversationList(); } @@ -267,475 +266,4 @@ public partial class ChatWindow var maxW = (scrollWidth - 120) * 0.90; return Math.Clamp(maxW, 500, 1200); } - - private StackPanel CreateStreamingContainer(out TextBlock streamText) - { - var msgMaxWidth = GetMessageMaxWidth(); - var container = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Left, - Width = msgMaxWidth, - MaxWidth = msgMaxWidth, - Margin = new Thickness(40, 8, 80, 8), - Opacity = 0, - RenderTransform = new TranslateTransform(0, 10) - }; - - // 컨테이너 페이드인 + 슬라이드 업 - container.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); - ((TranslateTransform)container.RenderTransform).BeginAnimation( - TranslateTransform.YProperty, - new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) - { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); - - var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var aiIcon = new TextBlock - { - Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, - Foreground = ThemeResourceHelper.Accent(this), - VerticalAlignment = VerticalAlignment.Center - }; - // AI 아이콘 펄스 애니메이션 (응답 대기 중) - aiIcon.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) - { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, - EasingFunction = new SineEase() }); - _activeAiIcon = aiIcon; - Grid.SetColumn(aiIcon, 0); - headerGrid.Children.Add(aiIcon); - - var (streamAgentName, _, _) = GetAgentIdentity(); - var aiNameTb = new TextBlock - { - Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = ThemeResourceHelper.Accent(this), - Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center - }; - Grid.SetColumn(aiNameTb, 1); - headerGrid.Children.Add(aiNameTb); - - // 실시간 경과 시간 (헤더 우측) - _elapsedLabel = new TextBlock - { - Text = "0s", - FontSize = 10.5, - Foreground = ThemeResourceHelper.Secondary(this), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.5, - }; - Grid.SetColumn(_elapsedLabel, 2); - headerGrid.Children.Add(_elapsedLabel); - - container.Children.Add(headerGrid); - - streamText = new TextBlock - { - Text = "\u258c", // 블록 커서만 표시 (첫 청크 전) - FontSize = 13.5, - Foreground = ThemeResourceHelper.Secondary(this), - TextWrapping = TextWrapping.Wrap, LineHeight = 22, - }; - container.Children.Add(streamText); - return container; - } - - // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── - - private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) - { - // 스트리밍 plaintext 블록 제거 - container.Children.Remove(streamText); - - // 마크다운 렌더링 - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var codeBgBrush = ThemeResourceHelper.Hint(this); - - var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); - mdPanel.Margin = new Thickness(0, 0, 0, 4); - mdPanel.Opacity = 0; - container.Children.Add(mdPanel); - mdPanel.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); - - // 액션 버튼 바 + 토큰 표시 - var btnColor = ThemeResourceHelper.Secondary(this); - var capturedContent = finalContent; - var actionBar = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 6, 0, 0) - }; - actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => - { - try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ } - })); - actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); - actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); - AddLinkedFeedbackButtons(actionBar, btnColor, message); - - container.Children.Add(actionBar); - - // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) - var elapsed = DateTime.UtcNow - _streamStartTime; - var elapsedText = elapsed.TotalSeconds < 60 - ? $"{elapsed.TotalSeconds:0.#}s" - : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; - - var usage = _llm.LastTokenUsage; - // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 - var isAgentTab = _activeTab is "Cowork" or "Code"; - var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 - ? _agentCumulativeInputTokens - : usage?.PromptTokens ?? 0; - var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0 - ? _agentCumulativeOutputTokens - : usage?.CompletionTokens ?? 0; - - if (displayInput > 0 || displayOutput > 0) - { - UpdateStatusTokens(displayInput, displayOutput); - Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput); - } - string tokenText; - if (displayInput > 0 || displayOutput > 0) - tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; - else if (usage != null) - tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; - else - tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; - - var metaText = new TextBlock - { - Text = $"{elapsedText} · {tokenText}", - FontSize = 10.5, - Foreground = ThemeResourceHelper.Secondary(this), - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 6, 0, 0), - Opacity = 0.6, - }; - container.Children.Add(metaText); - - // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 - var chips = ParseSuggestionChips(finalContent); - if (chips.Count > 0) - { - var chipPanel = new WrapPanel - { - Margin = new Thickness(0, 8, 0, 4), - HorizontalAlignment = HorizontalAlignment.Left, - }; - foreach (var (num, label) in chips) - { - var chipBorder = new Border - { - Background = ThemeResourceHelper.ItemBg(this), - BorderBrush = ThemeResourceHelper.Border(this), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Margin = new Thickness(0, 0, 8, 6), - Cursor = Cursors.Hand, - RenderTransformOrigin = new Point(0.5, 0.5), - RenderTransform = new ScaleTransform(1, 1), - }; - chipBorder.Child = new TextBlock - { - Text = $"{num}. {label}", - FontSize = 12.5, - Foreground = ThemeResourceHelper.Primary(this), - }; - - var chipHover = ThemeResourceHelper.HoverBg(this); - var chipNormal = ThemeResourceHelper.ItemBg(this); - chipBorder.MouseEnter += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } - }; - chipBorder.MouseLeave += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } - }; - - var capturedLabel = $"{num}. {label}"; - var capturedPanel = chipPanel; - chipBorder.MouseLeftButtonDown += (_, _) => - { - // 칩 패널 제거 (1회용) - if (capturedPanel.Parent is Panel parent) - parent.Children.Remove(capturedPanel); - // 선택한 옵션을 사용자 메시지로 전송 - InputBox.Text = capturedLabel; - _ = SendMessageAsync(); - }; - chipPanel.Children.Add(chipBorder); - } - container.Children.Add(chipPanel); - } - } - - /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) - private static List<(string Num, string Label)> ParseSuggestionChips(string content) - { - var chips = new List<(string, string)>(); - if (string.IsNullOrEmpty(content)) return chips; - - var lines = content.Split('\n'); - // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) - var candidates = new List<(string, string)>(); - var lastBlockStart = -1; - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i].Trim(); - // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 - var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); - if (m.Success) - { - if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) - { - if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - else - { - // 새로운 블록 시작 - lastBlockStart = i; - candidates.Clear(); - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - } - else if (!string.IsNullOrWhiteSpace(line)) - { - // 번호 목록이 아닌 줄이 나오면 블록 리셋 - lastBlockStart = -1; - candidates.Clear(); - } - // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) - } - - // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 - if (candidates.Count >= 2 && candidates.Count <= 10) - chips.AddRange(candidates); - - return chips; - } - - /// 토큰 수를 k/m 단위로 포맷 - private static string FormatTokenCount(int count) => count switch - { - >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", - >= 1_000 => $"{count / 1_000.0:0.#}k", - _ => count.ToString(), - }; - - /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) - private static int EstimateTokenCount(string text) - { - if (string.IsNullOrEmpty(text)) return 0; - // 한국어 문자 비율에 따라 가중 - int cjk = 0; - foreach (var c in text) - if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; - double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; - double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 - return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); - } - - // ─── 생성 중지 ────────────────────────────────────────────────────── - - private void StopGeneration() - { - _streamCts?.Cancel(); - } - - // ─── 대화 분기 (Fork) ────────────────────────────────────────────── - - private void ForkConversation(ChatConversation source, int atIndex) - { - var branchCount = _storage.LoadAllMeta() - .Count(m => m.ParentId == source.Id) + 1; - - var fork = new ChatConversation - { - Title = $"{source.Title} (분기 {branchCount})", - Tab = source.Tab, - Category = source.Category, - WorkFolder = source.WorkFolder, - SystemCommand = source.SystemCommand, - ParentId = source.Id, - BranchLabel = $"분기 {branchCount}", - BranchAtIndex = atIndex, - }; - - // 분기 시점까지의 메시지 복제 - for (int i = 0; i <= atIndex && i < source.Messages.Count; i++) - { - var m = source.Messages[i]; - fork.Messages.Add(new ChatMessage - { - Role = m.Role, - Content = m.Content, - Timestamp = m.Timestamp, - }); - } - - try - { - _storage.Save(fork); - ShowToast($"분기 생성: {fork.Title}"); - - // 분기 대화로 전환 - lock (_convLock) _currentConversation = fork; - ChatTitle.Text = fork.Title; - RenderMessages(); - RefreshConversationList(); - } - catch (Exception ex) - { - ShowToast($"분기 실패: {ex.Message}", "\uE783"); - } - } - - // ─── 커맨드 팔레트 ───────────────────────────────────────────────── - - private void OpenCommandPalette() - { - var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; - palette.ShowDialog(); - } - - private void ExecuteCommand(string commandId) - { - switch (commandId) - { - case "tab:chat": TabChat.IsChecked = true; break; - case "tab:cowork": TabCowork.IsChecked = true; break; - case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; - case "new_conversation": StartNewConversation(); break; - case "search_conversation": ToggleMessageSearch(); break; - case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; - case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; - case "open_statistics": new StatisticsWindow().Show(); break; - case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; - case "toggle_devmode": - var llm = Llm; - llm.DevMode = !llm.DevMode; - _settings.Save(); - UpdateAnalyzerButtonVisibility(); - ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); - break; - case "open_audit_log": - try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ } - break; - case "paste_clipboard": - try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ } - break; - case "export_conversation": ExportConversation(); break; - } - } - - private void ExportConversation() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null || conv.Messages.Count == 0) return; - - var dlg = new Microsoft.Win32.SaveFileDialog - { - FileName = $"{conv.Title}", - DefaultExt = ".md", - Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" - }; - if (dlg.ShowDialog() != true) return; - - var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); - string content; - - if (ext == ".json") - { - content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }); - } - else if (dlg.FileName.EndsWith(".pdf.html")) - { - // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 - content = PdfExportService.BuildHtml(conv); - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - PdfExportService.OpenInBrowser(dlg.FileName); - ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); - return; - } - else if (ext == ".html") - { - content = ExportToHtml(conv); - } - else - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"# {conv.Title}"); - sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); - sb.AppendLine(); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var label = msg.Role == "user" ? "**사용자**" : "**AI**"; - sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); - sb.AppendLine(); - sb.AppendLine(msg.Content); - if (msg.AttachedFiles is { Count: > 0 }) - { - sb.AppendLine(); - sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); - } - sb.AppendLine(); - sb.AppendLine("---"); - sb.AppendLine(); - } - content = sb.ToString(); - } - - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - } - - private static string ExportToHtml(ChatConversation conv) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(""); - sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); - sb.AppendLine(""); - sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); - sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var cls = msg.Role == "user" ? "user" : "ai"; - var label = msg.Role == "user" ? "사용자" : "AI"; - sb.AppendLine($"
"); - sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); - sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); - sb.AppendLine("
"); - } - - sb.AppendLine(""); - return sb.ToString(); - } } diff --git a/src/AxCopilot/Views/ChatWindow.StreamingUI.cs b/src/AxCopilot/Views/ChatWindow.StreamingUI.cs new file mode 100644 index 0000000..4d2ecc3 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.StreamingUI.cs @@ -0,0 +1,303 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private StackPanel CreateStreamingContainer(out TextBlock streamText) + { + var msgMaxWidth = GetMessageMaxWidth(); + var container = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Left, + Width = msgMaxWidth, + MaxWidth = msgMaxWidth, + Margin = new Thickness(40, 8, 80, 8), + Opacity = 0, + RenderTransform = new TranslateTransform(0, 10) + }; + + // 컨테이너 페이드인 + 슬라이드 업 + container.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); + ((TranslateTransform)container.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) + { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); + + var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var aiIcon = new TextBlock + { + Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, + Foreground = ThemeResourceHelper.Accent(this), + VerticalAlignment = VerticalAlignment.Center + }; + // AI 아이콘 펄스 애니메이션 (응답 대기 중) + aiIcon.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) + { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, + EasingFunction = new SineEase() }); + _activeAiIcon = aiIcon; + Grid.SetColumn(aiIcon, 0); + headerGrid.Children.Add(aiIcon); + + var (streamAgentName, _, _) = GetAgentIdentity(); + var aiNameTb = new TextBlock + { + Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold, + Foreground = ThemeResourceHelper.Accent(this), + Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(aiNameTb, 1); + headerGrid.Children.Add(aiNameTb); + + // 실시간 경과 시간 (헤더 우측) + _elapsedLabel = new TextBlock + { + Text = "0s", + FontSize = 10.5, + Foreground = ThemeResourceHelper.Secondary(this), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.5, + }; + Grid.SetColumn(_elapsedLabel, 2); + headerGrid.Children.Add(_elapsedLabel); + + container.Children.Add(headerGrid); + + streamText = new TextBlock + { + Text = "\u258c", // 블록 커서만 표시 (첫 청크 전) + FontSize = 13.5, + Foreground = ThemeResourceHelper.Secondary(this), + TextWrapping = TextWrapping.Wrap, LineHeight = 22, + }; + container.Children.Add(streamText); + return container; + } + + // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── + + private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) + { + // 스트리밍 plaintext 블록 제거 + container.Children.Remove(streamText); + + // 마크다운 렌더링 + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var codeBgBrush = ThemeResourceHelper.Hint(this); + + var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); + mdPanel.Margin = new Thickness(0, 0, 0, 4); + mdPanel.Opacity = 0; + container.Children.Add(mdPanel); + mdPanel.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); + + // 액션 버튼 바 + 토큰 표시 + var btnColor = ThemeResourceHelper.Secondary(this); + var capturedContent = finalContent; + var actionBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 6, 0, 0) + }; + actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => + { + try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ } + })); + actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); + actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); + AddLinkedFeedbackButtons(actionBar, btnColor, message); + + container.Children.Add(actionBar); + + // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) + var elapsed = DateTime.UtcNow - _streamStartTime; + var elapsedText = elapsed.TotalSeconds < 60 + ? $"{elapsed.TotalSeconds:0.#}s" + : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + + var usage = _llm.LastTokenUsage; + // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 + var isAgentTab = _activeTab is "Cowork" or "Code"; + var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 + ? _agentCumulativeInputTokens + : usage?.PromptTokens ?? 0; + var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0 + ? _agentCumulativeOutputTokens + : usage?.CompletionTokens ?? 0; + + if (displayInput > 0 || displayOutput > 0) + { + UpdateStatusTokens(displayInput, displayOutput); + Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput); + } + string tokenText; + if (displayInput > 0 || displayOutput > 0) + tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; + else if (usage != null) + tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; + else + tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; + + var metaText = new TextBlock + { + Text = $"{elapsedText} · {tokenText}", + FontSize = 10.5, + Foreground = ThemeResourceHelper.Secondary(this), + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 6, 0, 0), + Opacity = 0.6, + }; + container.Children.Add(metaText); + + // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 + var chips = ParseSuggestionChips(finalContent); + if (chips.Count > 0) + { + var chipPanel = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 4), + HorizontalAlignment = HorizontalAlignment.Left, + }; + foreach (var (num, label) in chips) + { + var chipBorder = new Border + { + Background = ThemeResourceHelper.ItemBg(this), + BorderBrush = ThemeResourceHelper.Border(this), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 6), + Cursor = Cursors.Hand, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + chipBorder.Child = new TextBlock + { + Text = $"{num}. {label}", + FontSize = 12.5, + Foreground = ThemeResourceHelper.Primary(this), + }; + + var chipHover = ThemeResourceHelper.HoverBg(this); + var chipNormal = ThemeResourceHelper.ItemBg(this); + chipBorder.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } + }; + chipBorder.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } + }; + + var capturedLabel = $"{num}. {label}"; + var capturedPanel = chipPanel; + chipBorder.MouseLeftButtonDown += (_, _) => + { + // 칩 패널 제거 (1회용) + if (capturedPanel.Parent is Panel parent) + parent.Children.Remove(capturedPanel); + // 선택한 옵션을 사용자 메시지로 전송 + InputBox.Text = capturedLabel; + _ = SendMessageAsync(); + }; + chipPanel.Children.Add(chipBorder); + } + container.Children.Add(chipPanel); + } + } + + /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) + private static List<(string Num, string Label)> ParseSuggestionChips(string content) + { + var chips = new List<(string, string)>(); + if (string.IsNullOrEmpty(content)) return chips; + + var lines = content.Split('\n'); + // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) + var candidates = new List<(string, string)>(); + var lastBlockStart = -1; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 + var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); + if (m.Success) + { + if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) + { + if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + else + { + // 새로운 블록 시작 + lastBlockStart = i; + candidates.Clear(); + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + } + else if (!string.IsNullOrWhiteSpace(line)) + { + // 번호 목록이 아닌 줄이 나오면 블록 리셋 + lastBlockStart = -1; + candidates.Clear(); + } + // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) + } + + // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 + if (candidates.Count >= 2 && candidates.Count <= 10) + chips.AddRange(candidates); + + return chips; + } + + /// 토큰 수를 k/m 단위로 포맷 + private static string FormatTokenCount(int count) => count switch + { + >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", + >= 1_000 => $"{count / 1_000.0:0.#}k", + _ => count.ToString(), + }; + + /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) + private static int EstimateTokenCount(string text) + { + if (string.IsNullOrEmpty(text)) return 0; + // 한국어 문자 비율에 따라 가중 + int cjk = 0; + foreach (var c in text) + if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; + double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; + double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 + return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); + } + + // ─── 생성 중지 ────────────────────────────────────────────────────── + + private void StopGeneration() + { + _streamCts?.Cancel(); + } +} diff --git a/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs b/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs new file mode 100644 index 0000000..96373b8 --- /dev/null +++ b/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs @@ -0,0 +1,266 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class HelpDetailWindow +{ + // ─── 테마 색상 헬퍼 ─────────────────────────────────────────────────────── + private Brush ThemeAccent => TryFindResource("AccentColor") as Brush ?? ParseColor("#4B5EFC"); + private Brush ThemePrimary => TryFindResource("PrimaryText") as Brush ?? Brushes.White; + private Brush ThemeSecondary => TryFindResource("SecondaryText") as Brush ?? ParseColor("#8899CC"); + + // ─── 상단 3탭 메뉴 빌드 ────────────────────────────────────────────────── + + private void BuildTopMenu() + { + TopMenuBar.Children.Clear(); + foreach (var (key, label, icon) in TopMenus) + { + var k = key; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 14, + Foreground = ThemeAccent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center + }); + + var btn = new Button + { + Content = sp, + FontWeight = FontWeights.SemiBold, + Padding = new Thickness(16, 8, 16, 8), + Margin = new Thickness(4, 0, 4, 0), + Cursor = Cursors.Hand, + Background = Brushes.Transparent, + Foreground = ThemeSecondary, + BorderThickness = new Thickness(0, 0, 0, 2), + BorderBrush = Brushes.Transparent, + Tag = k, + }; + btn.Click += (_, _) => SwitchTopMenu(k); + TopMenuBar.Children.Add(btn); + } + } + + private void SwitchTopMenu(TopMenu menu) + { + _currentTopMenu = menu; + + // 상단 메뉴 하이라이트 + foreach (var child in TopMenuBar.Children) + { + if (child is Button btn) + { + var isActive = btn.Tag is TopMenu t && t == menu; + btn.BorderBrush = isActive ? ThemeAccent : Brushes.Transparent; + btn.Foreground = isActive ? ThemePrimary : ThemeSecondary; + + // 내부 TextBlock의 Foreground도 업데이트 + if (btn.Content is StackPanel sp) + { + foreach (var spChild in sp.Children) + { + if (spChild is TextBlock tb && tb.FontFamily.Source != "Segoe MDL2 Assets") + tb.Foreground = btn.Foreground; + } + } + } + } + + // 하위 카테고리 탭 빌드 + switch (menu) + { + case TopMenu.Overview: + BuildCategoryBarFor(_overviewItems); + break; + case TopMenu.Shortcuts: + BuildCategoryBarFor(_shortcutItems); + break; + case TopMenu.Prefixes: + BuildCategoryBarFor(_prefixItems); + break; + } + } + + // ─── 하위 카테고리 탭 빌드 ──────────────────────────────────────────────── + + private void BuildCategoryBarFor(List sourceItems) + { + var seen = new HashSet(); + _categories = new List { CatAll }; + + // 예약어 탭에서만 인기 카테고리 표시 + if (_currentTopMenu == TopMenu.Prefixes) + _categories.Add(CatPopular); + + foreach (var item in sourceItems) + { + if (seen.Add(item.Category)) + _categories.Add(item.Category); + } + + BuildCategoryBar(); + NavigateToPage(0); + } + + private void BuildCategoryBar() + { + CategoryBar.Children.Clear(); + for (int i = 0; i < _categories.Count; i++) + { + var cat = _categories[i]; + var idx = i; + + var btn = new Button + { + Content = cat, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Padding = new Thickness(9, 4, 9, 4), + Margin = new Thickness(0, 0, 3, 0), + Cursor = Cursors.Hand, + Background = Brushes.Transparent, + Foreground = ThemeSecondary, + BorderThickness = new Thickness(0), + Tag = idx, + }; + + btn.Click += (_, _) => NavigateToPage(idx); + CategoryBar.Children.Add(btn); + } + } + + // ─── 페이지 네비게이션 ────────────────────────────────────────────────── + + private List GetCurrentSourceItems() => _currentTopMenu switch + { + TopMenu.Overview => _overviewItems, + TopMenu.Shortcuts => _shortcutItems, + TopMenu.Prefixes => _prefixItems, + _ => _overviewItems, + }; + + private void NavigateToPage(int pageIndex) + { + if (_categories.Count == 0) return; + _currentPage = Math.Clamp(pageIndex, 0, _categories.Count - 1); + + var source = GetCurrentSourceItems(); + var cat = _categories[_currentPage]; + List filtered; + + if (cat == CatAll) + filtered = source; + else if (cat == CatPopular) + { + var popularCmds = new HashSet(StringComparer.OrdinalIgnoreCase) + { "파일/폴더", "?", "#", "!", "clip", "pipe", "diff", "win" }; + filtered = source.Where(i => + popularCmds.Contains(i.Command) || + i.Command.StartsWith("?") || + i.Title.Contains("검색") || + i.Title.Contains("클립보드") || + (i.Title.Contains("파일") && i.Category != "키보드") + ).ToList(); + } + else + filtered = source.Where(i => i.Category == cat).ToList(); + + ItemsHost.ItemsSource = filtered; + + // 탭 하이라이트 + for (int i = 0; i < CategoryBar.Children.Count; i++) + { + if (CategoryBar.Children[i] is Button btn) + { + if (i == _currentPage) + { + btn.Background = ThemeAccent; + btn.Foreground = Brushes.White; + } + else + { + btn.Background = Brushes.Transparent; + btn.Foreground = ThemeSecondary; + } + } + } + + PageIndicator.Text = $"{cat} ({filtered.Count}개)"; + PrevBtn.Visibility = _currentPage > 0 ? Visibility.Visible : Visibility.Hidden; + NextBtn.Visibility = _currentPage < _categories.Count - 1 ? Visibility.Visible : Visibility.Hidden; + } + + // ─── 이벤트 ──────────────────────────────────────────────────────────── + + private void OnKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Escape: + Close(); + break; + case Key.Left: + if (_currentPage > 0) NavigateToPage(_currentPage - 1); + e.Handled = true; + break; + case Key.Right: + if (_currentPage < _categories.Count - 1) NavigateToPage(_currentPage + 1); + e.Handled = true; + break; + // 1, 2, 3 숫자키로 상단 메뉴 전환 + case Key.D1: SwitchTopMenu(TopMenu.Overview); e.Handled = true; break; + case Key.D2: SwitchTopMenu(TopMenu.Shortcuts); e.Handled = true; break; + case Key.D3: SwitchTopMenu(TopMenu.Prefixes); e.Handled = true; break; + } + } + + private void Prev_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage - 1); + private void Next_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage + 1); + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) + { + var query = SearchBox.Text.Trim(); + if (string.IsNullOrEmpty(query)) + { + NavigateToPage(_currentPage); + return; + } + + var source = GetCurrentSourceItems(); + var filtered = source.Where(i => + i.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || + i.Command.Contains(query, StringComparison.OrdinalIgnoreCase) || + i.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || + i.Category.Contains(query, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + ItemsHost.ItemsSource = filtered; + PageIndicator.Text = $"검색: \"{query}\" ({filtered.Count}개)"; + } + + private void Window_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed) + try { DragMove(); } catch (Exception) { } + } + + private void Window_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) Close(); + } +} diff --git a/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs new file mode 100644 index 0000000..fcde08a --- /dev/null +++ b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs @@ -0,0 +1,168 @@ +namespace AxCopilot.Views; + +public partial class HelpDetailWindow +{ + // ─── 단축키 항목 빌드 ───────────────────────────────────────────────────── + + private static List BuildShortcutItems(string globalHotkey = "Alt+Space") + { + var items = new List(); + + // 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "Alt+Space" → "Alt + Space") + var hotkeyDisplay = globalHotkey.Replace("+", " + "); + + // ── 전역 단축키 ────────────────────────────────────────────────────── + items.Add(MakeShortcut("전역", hotkeyDisplay, + "AX Commander 열기/닫기", + "어느 창에서든 눌러 AX Commander를 즉시 호출하거나 닫습니다. 설정 › 일반에서 원하는 키 조합으로 변경할 수 있습니다.", + "\uE765", "#4B5EFC")); + items.Add(MakeShortcut("전역", "PrintScreen", + "화면 캡처 즉시 실행", + "AX Commander를 열지 않고 곧바로 캡처를 시작합니다. 설정 › 캡처 탭에서 '글로벌 단축키 활성화'를 켜야 동작합니다.", + "\uE722", "#BE185D")); + + // ── 런처 탐색 ──────────────────────────────────────────────────────── + items.Add(MakeShortcut("AX Commander 탐색", "Escape", + "창 닫기 / 이전 단계로", + "액션 모드(→ 로 진입)에 있을 때는 일반 검색 화면으로 돌아갑니다. 일반 화면이면 AX Commander를 숨깁니다.", + "\uE711", "#999999")); + items.Add(MakeShortcut("AX Commander 탐색", "Enter", + "선택 항목 실행", + "파일·앱이면 열기, URL이면 브라우저 열기, 시스템 명령이면 즉시 실행, 계산기 결과면 클립보드에 복사합니다.", + "\uE768", "#107C10")); + items.Add(MakeShortcut("AX Commander 탐색", "Shift + Enter", + "대형 텍스트(Large Type) 표시 / 클립보드 병합 실행", + "선택된 텍스트·검색어를 화면 전체에 크게 띄웁니다. 클립보드 병합 항목이 있을 때는 선택한 항목들을 줄바꿈으로 합쳐 클립보드에 복사합니다.", + "\uE8C1", "#8764B8")); + items.Add(MakeShortcut("AX Commander 탐색", "↑ / ↓", + "결과 목록 위/아래 이동", + "목록 끝에서 계속 누르면 처음/끝으로 순환합니다.", + "\uE74A", "#0078D4")); + items.Add(MakeShortcut("AX Commander 탐색", "PageUp / PageDown", + "목록 5칸 빠른 이동", + "한 번에 5항목씩 건너뜁니다. 빠른 목록 탐색에 유용합니다.", + "\uE74A", "#0078D4")); + items.Add(MakeShortcut("AX Commander 탐색", "Home / End", + "목록 처음 / 마지막 항목으로 점프", + "입력창 커서가 맨 앞(또는 입력이 없을 때)이면 첫 항목으로, 맨 끝이면 마지막 항목으로 선택이 이동합니다.", + "\uE74A", "#0078D4")); + items.Add(MakeShortcut("AX Commander 탐색", "→ (오른쪽 화살표)", + "액션 모드 진입", + "파일·앱 항목을 선택한 상태에서 → 를 누르면 경로 복사, 탐색기 열기, 관리자 실행, 터미널, 속성, 이름 변경, 삭제 메뉴가 나타납니다.", + "\uE76C", "#44546A")); + items.Add(MakeShortcut("AX Commander 탐색", "Tab", + "선택 항목 제목으로 자동완성", + "현재 선택된 항목의 이름을 입력창에 채웁니다. 이후 계속 타이핑하거나 Enter로 실행합니다.", + "\uE748", "#006EAF")); + items.Add(MakeShortcut("AX Commander 탐색", "Shift + ↑/↓", + "클립보드 병합 선택", + "클립보드 히스토리(# 모드) 에서 여러 항목을 이동하면서 선택/해제합니다. Shift+Enter로 선택한 항목들을 한 번에 붙여넣을 수 있습니다.", + "\uE8C1", "#B7791F")); + + // ── 런처 기능 단축키 ───────────────────────────────────────────────── + items.Add(MakeShortcut("런처 기능", "F1", + "도움말 창 열기", + "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.", + "\uE897", "#6B7280")); + items.Add(MakeShortcut("런처 기능", "F2", + "선택 파일 이름 변경", + "파일·폴더 항목을 선택한 상태에서 누르면 rename [경로] 형태로 입력창에 채워지고 이름 변경 핸들러가 실행됩니다.", + "\uE70F", "#6B2C91")); + items.Add(MakeShortcut("런처 기능", "F5", + "파일 인덱스 즉시 재구축", + "백그라운드에서 파일·앱 인덱싱을 다시 실행합니다. 새 파일을 추가했거나 목록이 오래됐을 때 사용합니다.", + "\uE72C", "#059669")); + items.Add(MakeShortcut("런처 기능", "Delete", + "최근 실행 목록에서 항목 제거", + "recent 목록에 있는 항목을 제거합니다. 확인 다이얼로그가 표시되며 OK를 눌러야 실제로 제거됩니다.", + "\uE74D", "#DC2626")); + items.Add(MakeShortcut("런처 기능", "Ctrl + ,", + "설정 창 열기", + "AX Copilot 설정 창을 엽니다. 런처가 자동으로 숨겨집니다.", + "\uE713", "#44546A")); + items.Add(MakeShortcut("런처 기능", "Ctrl + L", + "입력창 전체 초기화", + "현재 입력된 검색어·예약어를 모두 지우고 커서를 빈 입력창으로 돌립니다.", + "\uE894", "#4B5EFC")); + items.Add(MakeShortcut("런처 기능", "Ctrl + C", + "선택 항목 파일 이름 복사", + "파일·앱 항목이 선택된 경우 확장자를 제외한 파일 이름을 클립보드에 복사하고 토스트로 알립니다.", + "\uE8C8", "#8764B8")); + items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + C", + "선택 항목 전체 경로 복사", + "선택된 파일·폴더의 절대 경로(예: C:\\Users\\...)를 클립보드에 복사합니다.", + "\uE8C8", "#C55A11")); + items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + E", + "파일 탐색기에서 선택 항목 열기", + "Windows 탐색기가 열리고 해당 파일·폴더가 하이라이트 선택된 상태로 표시됩니다.", + "\uE838", "#107C10")); + items.Add(MakeShortcut("런처 기능", "Ctrl + Enter", + "관리자(UAC) 권한으로 실행", + "선택된 파일·앱을 UAC 권한 상승 후 실행합니다. 설치 프로그램이나 시스템 설정 앱에 유용합니다.", + "\uE7EF", "#C50F1F")); + items.Add(MakeShortcut("런처 기능", "Alt + Enter", + "파일 속성 대화 상자 열기", + "Windows의 '파일 속성' 창(크기·날짜·권한 등)을 엽니다.", + "\uE946", "#6B2C91")); + items.Add(MakeShortcut("런처 기능", "Ctrl + T", + "선택 항목 위치에서 터미널 열기", + "선택된 파일이면 해당 폴더에서, 폴더이면 그 경로에서 Windows Terminal(wt.exe)이 열립니다. wt가 없으면 cmd로 대체됩니다.", + "\uE756", "#323130")); + items.Add(MakeShortcut("런처 기능", "Ctrl + P", + "즐겨찾기 즉시 추가 / 제거 (핀)", + "파일·폴더 항목을 선택한 상태에서 누르면 favorites.json 에 추가하거나 이미 있으면 제거합니다. 토스트로 결과를 알립니다.", + "\uE734", "#D97706")); + items.Add(MakeShortcut("런처 기능", "Ctrl + B", + "즐겨찾기 목록 보기 / 닫기 토글", + "입력창이 'fav' 이면 초기화하고, 아니면 'fav' 를 입력해 즐겨찾기 목록을 표시합니다.", + "\uE735", "#D97706")); + items.Add(MakeShortcut("런처 기능", "Ctrl + R", + "최근 실행 목록 보기 / 닫기 토글", + "'recent' 를 입력해 최근 실행 항목을 표시합니다.", + "\uE81C", "#0078D4")); + items.Add(MakeShortcut("런처 기능", "Ctrl + H", + "클립보드 히스토리 목록 열기", + "'#' 를 입력해 클립보드에 저장된 최근 복사 항목 목록을 표시합니다.", + "\uE77F", "#8B2FC9")); + items.Add(MakeShortcut("런처 기능", "Ctrl + D", + "다운로드 폴더 바로가기", + "사용자 홈의 Downloads 폴더 경로를 입력창에 채워 탐색기로 열 수 있게 합니다.", + "\uE8B7", "#107C10")); + items.Add(MakeShortcut("런처 기능", "Ctrl + F", + "파일 검색 모드로 전환", + "입력창을 초기화하고 포커스를 이동합니다. 이후 파일명을 바로 타이핑해 검색할 수 있습니다.", + "\uE71E", "#4B5EFC")); + items.Add(MakeShortcut("런처 기능", "Ctrl + W", + "런처 창 즉시 닫기", + "현재 입력 내용에 관계없이 런처를 즉시 숨깁니다.", + "\uE711", "#9999BB")); + items.Add(MakeShortcut("런처 기능", "Ctrl + K", + "단축키 참조 모달 창 열기", + "모든 단축키와 설명을 보여주는 별도 모달 창이 열립니다. Esc 또는 닫기 버튼으로 닫습니다.", + "\uE8FD", "#4B5EFC")); + items.Add(MakeShortcut("런처 기능", "Ctrl + 1 ~ 9", + "N번째 결과 항목 바로 실행", + "목록에 번호 배지(1~9)가 표시된 항목을 해당 숫자 키로 즉시 실행합니다. 마우스 없이 빠른 실행에 유용합니다.", + "\uE8C4", "#107C10")); + + // ── 기타 창 단축키 ──────────────────────────────────────────────────── + items.Add(MakeShortcut("기타 창", "← / →", + "헬프 창 카테고리 이동", + "이 도움말 창에서 하위 카테고리 탭을 왼쪽/오른쪽으로 이동합니다.", + "\uE76B", "#4455AA")); + items.Add(MakeShortcut("기타 창", "1 / 2 / 3", + "헬프 창 상단 메뉴 전환", + "이 도움말 창에서 개요(1), 단축키 현황(2), 예약어 현황(3)을 키보드로 전환합니다.", + "\uE8BD", "#4455AA")); + items.Add(MakeShortcut("기타 창", "방향키 (캡처 중)", + "영역 선택 경계 1px 미세 조정", + "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 1픽셀씩 정밀 조정합니다.", + "\uE745", "#BE185D")); + items.Add(MakeShortcut("기타 창", "Shift + 방향키 (캡처 중)", + "영역 선택 경계 10px 이동", + "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 10픽셀씩 빠르게 이동합니다.", + "\uE745", "#BE185D")); + + return items; + } +} diff --git a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs index 6c87f69..a56434f 100644 --- a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs +++ b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs @@ -219,169 +219,7 @@ public partial class HelpDetailWindow : Window KeyDown += OnKeyDown; } - // ─── 단축키 항목 빌드 ───────────────────────────────────────────────────── - - private static List BuildShortcutItems(string globalHotkey = "Alt+Space") - { - var items = new List(); - - // 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "Alt+Space" → "Alt + Space") - var hotkeyDisplay = globalHotkey.Replace("+", " + "); - - // ── 전역 단축키 ────────────────────────────────────────────────────── - items.Add(MakeShortcut("전역", hotkeyDisplay, - "AX Commander 열기/닫기", - "어느 창에서든 눌러 AX Commander를 즉시 호출하거나 닫습니다. 설정 › 일반에서 원하는 키 조합으로 변경할 수 있습니다.", - "\uE765", "#4B5EFC")); - items.Add(MakeShortcut("전역", "PrintScreen", - "화면 캡처 즉시 실행", - "AX Commander를 열지 않고 곧바로 캡처를 시작합니다. 설정 › 캡처 탭에서 '글로벌 단축키 활성화'를 켜야 동작합니다.", - "\uE722", "#BE185D")); - - // ── 런처 탐색 ──────────────────────────────────────────────────────── - items.Add(MakeShortcut("AX Commander 탐색", "Escape", - "창 닫기 / 이전 단계로", - "액션 모드(→ 로 진입)에 있을 때는 일반 검색 화면으로 돌아갑니다. 일반 화면이면 AX Commander를 숨깁니다.", - "\uE711", "#999999")); - items.Add(MakeShortcut("AX Commander 탐색", "Enter", - "선택 항목 실행", - "파일·앱이면 열기, URL이면 브라우저 열기, 시스템 명령이면 즉시 실행, 계산기 결과면 클립보드에 복사합니다.", - "\uE768", "#107C10")); - items.Add(MakeShortcut("AX Commander 탐색", "Shift + Enter", - "대형 텍스트(Large Type) 표시 / 클립보드 병합 실행", - "선택된 텍스트·검색어를 화면 전체에 크게 띄웁니다. 클립보드 병합 항목이 있을 때는 선택한 항목들을 줄바꿈으로 합쳐 클립보드에 복사합니다.", - "\uE8C1", "#8764B8")); - items.Add(MakeShortcut("AX Commander 탐색", "↑ / ↓", - "결과 목록 위/아래 이동", - "목록 끝에서 계속 누르면 처음/끝으로 순환합니다.", - "\uE74A", "#0078D4")); - items.Add(MakeShortcut("AX Commander 탐색", "PageUp / PageDown", - "목록 5칸 빠른 이동", - "한 번에 5항목씩 건너뜁니다. 빠른 목록 탐색에 유용합니다.", - "\uE74A", "#0078D4")); - items.Add(MakeShortcut("AX Commander 탐색", "Home / End", - "목록 처음 / 마지막 항목으로 점프", - "입력창 커서가 맨 앞(또는 입력이 없을 때)이면 첫 항목으로, 맨 끝이면 마지막 항목으로 선택이 이동합니다.", - "\uE74A", "#0078D4")); - items.Add(MakeShortcut("AX Commander 탐색", "→ (오른쪽 화살표)", - "액션 모드 진입", - "파일·앱 항목을 선택한 상태에서 → 를 누르면 경로 복사, 탐색기 열기, 관리자 실행, 터미널, 속성, 이름 변경, 삭제 메뉴가 나타납니다.", - "\uE76C", "#44546A")); - items.Add(MakeShortcut("AX Commander 탐색", "Tab", - "선택 항목 제목으로 자동완성", - "현재 선택된 항목의 이름을 입력창에 채웁니다. 이후 계속 타이핑하거나 Enter로 실행합니다.", - "\uE748", "#006EAF")); - items.Add(MakeShortcut("AX Commander 탐색", "Shift + ↑/↓", - "클립보드 병합 선택", - "클립보드 히스토리(# 모드) 에서 여러 항목을 이동하면서 선택/해제합니다. Shift+Enter로 선택한 항목들을 한 번에 붙여넣을 수 있습니다.", - "\uE8C1", "#B7791F")); - - // ── 런처 기능 단축키 ───────────────────────────────────────────────── - items.Add(MakeShortcut("런처 기능", "F1", - "도움말 창 열기", - "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.", - "\uE897", "#6B7280")); - items.Add(MakeShortcut("런처 기능", "F2", - "선택 파일 이름 변경", - "파일·폴더 항목을 선택한 상태에서 누르면 rename [경로] 형태로 입력창에 채워지고 이름 변경 핸들러가 실행됩니다.", - "\uE70F", "#6B2C91")); - items.Add(MakeShortcut("런처 기능", "F5", - "파일 인덱스 즉시 재구축", - "백그라운드에서 파일·앱 인덱싱을 다시 실행합니다. 새 파일을 추가했거나 목록이 오래됐을 때 사용합니다.", - "\uE72C", "#059669")); - items.Add(MakeShortcut("런처 기능", "Delete", - "최근 실행 목록에서 항목 제거", - "recent 목록에 있는 항목을 제거합니다. 확인 다이얼로그가 표시되며 OK를 눌러야 실제로 제거됩니다.", - "\uE74D", "#DC2626")); - items.Add(MakeShortcut("런처 기능", "Ctrl + ,", - "설정 창 열기", - "AX Copilot 설정 창을 엽니다. 런처가 자동으로 숨겨집니다.", - "\uE713", "#44546A")); - items.Add(MakeShortcut("런처 기능", "Ctrl + L", - "입력창 전체 초기화", - "현재 입력된 검색어·예약어를 모두 지우고 커서를 빈 입력창으로 돌립니다.", - "\uE894", "#4B5EFC")); - items.Add(MakeShortcut("런처 기능", "Ctrl + C", - "선택 항목 파일 이름 복사", - "파일·앱 항목이 선택된 경우 확장자를 제외한 파일 이름을 클립보드에 복사하고 토스트로 알립니다.", - "\uE8C8", "#8764B8")); - items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + C", - "선택 항목 전체 경로 복사", - "선택된 파일·폴더의 절대 경로(예: C:\\Users\\...)를 클립보드에 복사합니다.", - "\uE8C8", "#C55A11")); - items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + E", - "파일 탐색기에서 선택 항목 열기", - "Windows 탐색기가 열리고 해당 파일·폴더가 하이라이트 선택된 상태로 표시됩니다.", - "\uE838", "#107C10")); - items.Add(MakeShortcut("런처 기능", "Ctrl + Enter", - "관리자(UAC) 권한으로 실행", - "선택된 파일·앱을 UAC 권한 상승 후 실행합니다. 설치 프로그램이나 시스템 설정 앱에 유용합니다.", - "\uE7EF", "#C50F1F")); - items.Add(MakeShortcut("런처 기능", "Alt + Enter", - "파일 속성 대화 상자 열기", - "Windows의 '파일 속성' 창(크기·날짜·권한 등)을 엽니다.", - "\uE946", "#6B2C91")); - items.Add(MakeShortcut("런처 기능", "Ctrl + T", - "선택 항목 위치에서 터미널 열기", - "선택된 파일이면 해당 폴더에서, 폴더이면 그 경로에서 Windows Terminal(wt.exe)이 열립니다. wt가 없으면 cmd로 대체됩니다.", - "\uE756", "#323130")); - items.Add(MakeShortcut("런처 기능", "Ctrl + P", - "즐겨찾기 즉시 추가 / 제거 (핀)", - "파일·폴더 항목을 선택한 상태에서 누르면 favorites.json 에 추가하거나 이미 있으면 제거합니다. 토스트로 결과를 알립니다.", - "\uE734", "#D97706")); - items.Add(MakeShortcut("런처 기능", "Ctrl + B", - "즐겨찾기 목록 보기 / 닫기 토글", - "입력창이 'fav' 이면 초기화하고, 아니면 'fav' 를 입력해 즐겨찾기 목록을 표시합니다.", - "\uE735", "#D97706")); - items.Add(MakeShortcut("런처 기능", "Ctrl + R", - "최근 실행 목록 보기 / 닫기 토글", - "'recent' 를 입력해 최근 실행 항목을 표시합니다.", - "\uE81C", "#0078D4")); - items.Add(MakeShortcut("런처 기능", "Ctrl + H", - "클립보드 히스토리 목록 열기", - "'#' 를 입력해 클립보드에 저장된 최근 복사 항목 목록을 표시합니다.", - "\uE77F", "#8B2FC9")); - items.Add(MakeShortcut("런처 기능", "Ctrl + D", - "다운로드 폴더 바로가기", - "사용자 홈의 Downloads 폴더 경로를 입력창에 채워 탐색기로 열 수 있게 합니다.", - "\uE8B7", "#107C10")); - items.Add(MakeShortcut("런처 기능", "Ctrl + F", - "파일 검색 모드로 전환", - "입력창을 초기화하고 포커스를 이동합니다. 이후 파일명을 바로 타이핑해 검색할 수 있습니다.", - "\uE71E", "#4B5EFC")); - items.Add(MakeShortcut("런처 기능", "Ctrl + W", - "런처 창 즉시 닫기", - "현재 입력 내용에 관계없이 런처를 즉시 숨깁니다.", - "\uE711", "#9999BB")); - items.Add(MakeShortcut("런처 기능", "Ctrl + K", - "단축키 참조 모달 창 열기", - "모든 단축키와 설명을 보여주는 별도 모달 창이 열립니다. Esc 또는 닫기 버튼으로 닫습니다.", - "\uE8FD", "#4B5EFC")); - items.Add(MakeShortcut("런처 기능", "Ctrl + 1 ~ 9", - "N번째 결과 항목 바로 실행", - "목록에 번호 배지(1~9)가 표시된 항목을 해당 숫자 키로 즉시 실행합니다. 마우스 없이 빠른 실행에 유용합니다.", - "\uE8C4", "#107C10")); - - // ── 기타 창 단축키 ──────────────────────────────────────────────────── - items.Add(MakeShortcut("기타 창", "← / →", - "헬프 창 카테고리 이동", - "이 도움말 창에서 하위 카테고리 탭을 왼쪽/오른쪽으로 이동합니다.", - "\uE76B", "#4455AA")); - items.Add(MakeShortcut("기타 창", "1 / 2 / 3", - "헬프 창 상단 메뉴 전환", - "이 도움말 창에서 개요(1), 단축키 현황(2), 예약어 현황(3)을 키보드로 전환합니다.", - "\uE8BD", "#4455AA")); - items.Add(MakeShortcut("기타 창", "방향키 (캡처 중)", - "영역 선택 경계 1px 미세 조정", - "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 1픽셀씩 정밀 조정합니다.", - "\uE745", "#BE185D")); - items.Add(MakeShortcut("기타 창", "Shift + 방향키 (캡처 중)", - "영역 선택 경계 10px 이동", - "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 10픽셀씩 빠르게 이동합니다.", - "\uE745", "#BE185D")); - - return items; - } + // ─── 정적 헬퍼 ─────────────────────────────────────────────────────────── private static SolidColorBrush ParseColor(string hex) { @@ -401,263 +239,6 @@ public partial class HelpDetailWindow : Window ColorBrush = ThemeResourceHelper.HexBrush(color) }; } - - // ─── 상단 3탭 메뉴 빌드 ────────────────────────────────────────────────── - - // ─── 테마 색상 헬퍼 ─────────────────────────────────────────────────────── - private Brush ThemeAccent => TryFindResource("AccentColor") as Brush ?? ParseColor("#4B5EFC"); - private Brush ThemePrimary => TryFindResource("PrimaryText") as Brush ?? Brushes.White; - private Brush ThemeSecondary => TryFindResource("SecondaryText") as Brush ?? ParseColor("#8899CC"); - - private void BuildTopMenu() - { - TopMenuBar.Children.Clear(); - foreach (var (key, label, icon) in TopMenus) - { - var k = key; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 14, - Foreground = ThemeAccent, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0) - }); - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center - }); - - var btn = new Button - { - Content = sp, - FontWeight = FontWeights.SemiBold, - Padding = new Thickness(16, 8, 16, 8), - Margin = new Thickness(4, 0, 4, 0), - Cursor = Cursors.Hand, - Background = Brushes.Transparent, - Foreground = ThemeSecondary, - BorderThickness = new Thickness(0, 0, 0, 2), - BorderBrush = Brushes.Transparent, - Tag = k, - }; - btn.Click += (_, _) => SwitchTopMenu(k); - TopMenuBar.Children.Add(btn); - } - } - - private void SwitchTopMenu(TopMenu menu) - { - _currentTopMenu = menu; - - // 상단 메뉴 하이라이트 - foreach (var child in TopMenuBar.Children) - { - if (child is Button btn) - { - var isActive = btn.Tag is TopMenu t && t == menu; - btn.BorderBrush = isActive ? ThemeAccent : Brushes.Transparent; - btn.Foreground = isActive ? ThemePrimary : ThemeSecondary; - - // 내부 TextBlock의 Foreground도 업데이트 - if (btn.Content is StackPanel sp) - { - foreach (var spChild in sp.Children) - { - if (spChild is TextBlock tb && tb.FontFamily.Source != "Segoe MDL2 Assets") - tb.Foreground = btn.Foreground; - } - } - } - } - - // 하위 카테고리 탭 빌드 - switch (menu) - { - case TopMenu.Overview: - BuildCategoryBarFor(_overviewItems); - break; - case TopMenu.Shortcuts: - BuildCategoryBarFor(_shortcutItems); - break; - case TopMenu.Prefixes: - BuildCategoryBarFor(_prefixItems); - break; - } - } - - // ─── 하위 카테고리 탭 빌드 ──────────────────────────────────────────────── - - private void BuildCategoryBarFor(List sourceItems) - { - var seen = new HashSet(); - _categories = new List { CatAll }; - - // 예약어 탭에서만 인기 카테고리 표시 - if (_currentTopMenu == TopMenu.Prefixes) - _categories.Add(CatPopular); - - foreach (var item in sourceItems) - { - if (seen.Add(item.Category)) - _categories.Add(item.Category); - } - - BuildCategoryBar(); - NavigateToPage(0); - } - - private void BuildCategoryBar() - { - CategoryBar.Children.Clear(); - for (int i = 0; i < _categories.Count; i++) - { - var cat = _categories[i]; - var idx = i; - - var btn = new Button - { - Content = cat, - FontSize = 10.5, - FontWeight = FontWeights.SemiBold, - Padding = new Thickness(9, 4, 9, 4), - Margin = new Thickness(0, 0, 3, 0), - Cursor = Cursors.Hand, - Background = Brushes.Transparent, - Foreground = ThemeSecondary, - BorderThickness = new Thickness(0), - Tag = idx, - }; - - btn.Click += (_, _) => NavigateToPage(idx); - CategoryBar.Children.Add(btn); - } - } - - // ─── 페이지 네비게이션 ────────────────────────────────────────────────── - - private List GetCurrentSourceItems() => _currentTopMenu switch - { - TopMenu.Overview => _overviewItems, - TopMenu.Shortcuts => _shortcutItems, - TopMenu.Prefixes => _prefixItems, - _ => _overviewItems, - }; - - private void NavigateToPage(int pageIndex) - { - if (_categories.Count == 0) return; - _currentPage = Math.Clamp(pageIndex, 0, _categories.Count - 1); - - var source = GetCurrentSourceItems(); - var cat = _categories[_currentPage]; - List filtered; - - if (cat == CatAll) - filtered = source; - else if (cat == CatPopular) - { - var popularCmds = new HashSet(StringComparer.OrdinalIgnoreCase) - { "파일/폴더", "?", "#", "!", "clip", "pipe", "diff", "win" }; - filtered = source.Where(i => - popularCmds.Contains(i.Command) || - i.Command.StartsWith("?") || - i.Title.Contains("검색") || - i.Title.Contains("클립보드") || - (i.Title.Contains("파일") && i.Category != "키보드") - ).ToList(); - } - else - filtered = source.Where(i => i.Category == cat).ToList(); - - ItemsHost.ItemsSource = filtered; - - // 탭 하이라이트 - for (int i = 0; i < CategoryBar.Children.Count; i++) - { - if (CategoryBar.Children[i] is Button btn) - { - if (i == _currentPage) - { - btn.Background = ThemeAccent; - btn.Foreground = Brushes.White; - } - else - { - btn.Background = Brushes.Transparent; - btn.Foreground = ThemeSecondary; - } - } - } - - PageIndicator.Text = $"{cat} ({filtered.Count}개)"; - PrevBtn.Visibility = _currentPage > 0 ? Visibility.Visible : Visibility.Hidden; - NextBtn.Visibility = _currentPage < _categories.Count - 1 ? Visibility.Visible : Visibility.Hidden; - } - - // ─── 이벤트 ──────────────────────────────────────────────────────────── - - private void OnKeyDown(object sender, KeyEventArgs e) - { - switch (e.Key) - { - case Key.Escape: - Close(); - break; - case Key.Left: - if (_currentPage > 0) NavigateToPage(_currentPage - 1); - e.Handled = true; - break; - case Key.Right: - if (_currentPage < _categories.Count - 1) NavigateToPage(_currentPage + 1); - e.Handled = true; - break; - // 1, 2, 3 숫자키로 상단 메뉴 전환 - case Key.D1: SwitchTopMenu(TopMenu.Overview); e.Handled = true; break; - case Key.D2: SwitchTopMenu(TopMenu.Shortcuts); e.Handled = true; break; - case Key.D3: SwitchTopMenu(TopMenu.Prefixes); e.Handled = true; break; - } - } - - private void Prev_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage - 1); - private void Next_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage + 1); - private void Close_Click(object sender, RoutedEventArgs e) => Close(); - - private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) - { - var query = SearchBox.Text.Trim(); - if (string.IsNullOrEmpty(query)) - { - NavigateToPage(_currentPage); - return; - } - - var source = GetCurrentSourceItems(); - var filtered = source.Where(i => - i.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || - i.Command.Contains(query, StringComparison.OrdinalIgnoreCase) || - i.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || - i.Category.Contains(query, StringComparison.OrdinalIgnoreCase) - ).ToList(); - - ItemsHost.ItemsSource = filtered; - PageIndicator.Text = $"검색: \"{query}\" ({filtered.Count}개)"; - } - - private void Window_MouseDown(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed) - try { DragMove(); } catch (Exception) { } - } - - private void Window_KeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Escape) Close(); - } } public class HelpItemModel