[Phase47] 대형 파일 분할 리팩터링 3차 — 8개 신규 파셜 파일 생성
## 분할 대상 및 결과 ### ChatWindow.ResponseHandling.cs (741줄 → 269줄) - ChatWindow.StreamingUI.cs (303줄, 신규): CreateStreamingContainer, FinalizeStreamingContainer, ParseSuggestionChips, FormatTokenCount, EstimateTokenCount, StopGeneration - ChatWindow.ConversationExport.cs (188줄, 신규): ForkConversation, OpenCommandPalette, ExecuteCommand, ExportConversation, ExportToHtml ### ChatWindow.PreviewAndFiles.cs (709줄 → ~340줄) - ChatWindow.PreviewPopup.cs (~230줄, 신규): ShowPreviewTabContextMenu, OpenPreviewPopupWindow, _previewTabPopup 필드 ### HelpDetailWindow.xaml.cs (673줄 → 254줄) - HelpDetailWindow.Shortcuts.cs (168줄, 신규): BuildShortcutItems() 정적 메서드 (단축키 항목 160개+ 생성) - HelpDetailWindow.Navigation.cs (266줄, 신규): 테마 프로퍼티, BuildTopMenu/SwitchTopMenu, BuildCategoryBar, NavigateToPage, 이벤트 핸들러 - partial class 전환: `public partial class HelpDetailWindow : Window` ### SkillService.cs (661줄 → 386줄) - SkillService.Import.cs (203줄, 신규): ExportSkill, ImportSkills, MapToolNames — 가져오기/내보내기 섹션 - SkillDefinition.cs (81줄, 신규): SkillDefinition 클래스 독립 파일로 분리 (별도 최상위 클래스) - partial class 전환: `public static partial class SkillService` ## NEXT_ROADMAP.md Phase 46 완료 항목 추가 ## 빌드 결과: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
81
src/AxCopilot/Services/Agent/SkillDefinition.cs
Normal file
81
src/AxCopilot/Services/Agent/SkillDefinition.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>스킬 정의 (*.skill.md에서 로드).</summary>
|
||||
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; } = "";
|
||||
|
||||
/// <summary>런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음.</summary>
|
||||
public string Requires { get; init; } = "";
|
||||
|
||||
/// <summary>표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능.</summary>
|
||||
public string Tabs { get; init; } = "all";
|
||||
|
||||
// ── Phase 24: CC 고급 프론트매터 필드 ──
|
||||
|
||||
/// <summary>실행 컨텍스트. "fork"이면 격리된 서브에이전트에서 실행.</summary>
|
||||
public string Context { get; init; } = "";
|
||||
|
||||
/// <summary>이 스킬에 사용할 모델 오버라이드. 빈 문자열이면 기본 모델 사용.</summary>
|
||||
public string ModelOverride { get; init; } = "";
|
||||
|
||||
/// <summary>사용자가 /로 호출 가능한지 여부. false면 AI만 사용.</summary>
|
||||
public bool UserInvocable { get; init; } = true;
|
||||
|
||||
/// <summary>자동 활성화 경로 패턴. "**/*.py" 등 — 매칭 파일 터치 시 스킬 자동 제안.</summary>
|
||||
public string Paths { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 실행 중에만 활성화되는 스코프 훅 정의 (JSON).</summary>
|
||||
public string ScopedHooks { get; init; } = "";
|
||||
|
||||
/// <summary>프론트매터 arguments 필드. 명명된 인수 목록.</summary>
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트.</summary>
|
||||
public string WhenToUse { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 버전.</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.</summary>
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
/// <summary>context:fork 설정인지 여부.</summary>
|
||||
public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>지정 탭에서 이 스킬을 표시할지 판정합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>SKILL.md 표준 폴더 형식인지 여부.</summary>
|
||||
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>비가용 시 사용자에게 표시할 힌트 메시지.</summary>
|
||||
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..]))} 필요)" : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/AxCopilot/Services/Agent/SkillService.Import.cs
Normal file
203
src/AxCopilot/Services/Agent/SkillService.Import.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public static partial class SkillService
|
||||
{
|
||||
// ─── 가져오기/내보내기 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 스킬을 zip 파일로 내보냅니다.
|
||||
/// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더).
|
||||
/// </summary>
|
||||
/// <returns>생성된 zip 파일 경로. 실패 시 null.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// zip 파일에서 스킬을 가져옵니다.
|
||||
/// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다.
|
||||
/// </summary>
|
||||
/// <returns>가져온 스킬 수. 0이면 실패.</returns>
|
||||
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<string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 도구 이름 매핑 (외부 스킬 호환) ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다.
|
||||
/// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> 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",
|
||||
};
|
||||
|
||||
/// <summary>스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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\) 또는 앱 기본 폴더에서 로드합니다.
|
||||
/// </summary>
|
||||
public static class SkillService
|
||||
public static partial class SkillService
|
||||
{
|
||||
private static List<SkillDefinition> _skills = new();
|
||||
private static string _lastFolder = "";
|
||||
@@ -203,201 +202,6 @@ public static class SkillService
|
||||
""");
|
||||
}
|
||||
|
||||
// ─── 가져오기/내보내기 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 스킬을 zip 파일로 내보냅니다.
|
||||
/// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더).
|
||||
/// </summary>
|
||||
/// <returns>생성된 zip 파일 경로. 실패 시 null.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// zip 파일에서 스킬을 가져옵니다.
|
||||
/// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다.
|
||||
/// </summary>
|
||||
/// <returns>가져온 스킬 수. 0이면 실패.</returns>
|
||||
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<string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 도구 이름 매핑 (외부 스킬 호환) ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다.
|
||||
/// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> 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",
|
||||
};
|
||||
|
||||
/// <summary>스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 내부 메서드 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -580,82 +384,3 @@ public static class SkillService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>스킬 정의 (*.skill.md에서 로드).</summary>
|
||||
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; } = "";
|
||||
|
||||
/// <summary>런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음.</summary>
|
||||
public string Requires { get; init; } = "";
|
||||
|
||||
/// <summary>표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능.</summary>
|
||||
public string Tabs { get; init; } = "all";
|
||||
|
||||
// ── Phase 24: CC 고급 프론트매터 필드 ──
|
||||
|
||||
/// <summary>실행 컨텍스트. "fork"이면 격리된 서브에이전트에서 실행.</summary>
|
||||
public string Context { get; init; } = "";
|
||||
|
||||
/// <summary>이 스킬에 사용할 모델 오버라이드. 빈 문자열이면 기본 모델 사용.</summary>
|
||||
public string ModelOverride { get; init; } = "";
|
||||
|
||||
/// <summary>사용자가 /로 호출 가능한지 여부. false면 AI만 사용.</summary>
|
||||
public bool UserInvocable { get; init; } = true;
|
||||
|
||||
/// <summary>자동 활성화 경로 패턴. "**/*.py" 등 — 매칭 파일 터치 시 스킬 자동 제안.</summary>
|
||||
public string Paths { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 실행 중에만 활성화되는 스코프 훅 정의 (JSON).</summary>
|
||||
public string ScopedHooks { get; init; } = "";
|
||||
|
||||
/// <summary>프론트매터 arguments 필드. 명명된 인수 목록.</summary>
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트.</summary>
|
||||
public string WhenToUse { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 버전.</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.</summary>
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
/// <summary>context:fork 설정인지 여부.</summary>
|
||||
public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>지정 탭에서 이 스킬을 표시할지 판정합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>SKILL.md 표준 폴더 형식인지 여부.</summary>
|
||||
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>비가용 시 사용자에게 표시할 힌트 메시지.</summary>
|
||||
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..]))} 필요)" : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
src/AxCopilot/Views/ChatWindow.ConversationExport.cs
Normal file
188
src/AxCopilot/Views/ChatWindow.ConversationExport.cs
Normal file
@@ -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("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
|
||||
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
|
||||
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
|
||||
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
|
||||
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
|
||||
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
|
||||
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
|
||||
|
||||
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($"<div class='msg {cls}'>");
|
||||
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
|
||||
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -459,251 +459,4 @@ public partial class ChatWindow
|
||||
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>프리뷰를 별도 팝업 창에서 엽니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
259
src/AxCopilot/Views/ChatWindow.PreviewPopup.cs
Normal file
259
src/AxCopilot/Views/ChatWindow.PreviewPopup.cs
Normal file
@@ -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
|
||||
{
|
||||
// ─── 미리보기 팝업 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>프리뷰를 별도 팝업 창에서 엽니다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
|
||||
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(),
|
||||
};
|
||||
|
||||
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
|
||||
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("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
|
||||
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
|
||||
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
|
||||
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
|
||||
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
|
||||
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
|
||||
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
|
||||
|
||||
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($"<div class='msg {cls}'>");
|
||||
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
|
||||
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
303
src/AxCopilot/Views/ChatWindow.StreamingUI.cs
Normal file
303
src/AxCopilot/Views/ChatWindow.StreamingUI.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
|
||||
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(),
|
||||
};
|
||||
|
||||
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
266
src/AxCopilot/Views/HelpDetailWindow.Navigation.cs
Normal file
266
src/AxCopilot/Views/HelpDetailWindow.Navigation.cs
Normal file
@@ -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<HelpItemModel> sourceItems)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
_categories = new List<string> { 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<HelpItemModel> 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<HelpItemModel> filtered;
|
||||
|
||||
if (cat == CatAll)
|
||||
filtered = source;
|
||||
else if (cat == CatPopular)
|
||||
{
|
||||
var popularCmds = new HashSet<string>(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();
|
||||
}
|
||||
}
|
||||
168
src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs
Normal file
168
src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class HelpDetailWindow
|
||||
{
|
||||
// ─── 단축키 항목 빌드 ─────────────────────────────────────────────────────
|
||||
|
||||
private static List<HelpItemModel> BuildShortcutItems(string globalHotkey = "Alt+Space")
|
||||
{
|
||||
var items = new List<HelpItemModel>();
|
||||
|
||||
// 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "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;
|
||||
}
|
||||
}
|
||||
@@ -219,169 +219,7 @@ public partial class HelpDetailWindow : Window
|
||||
KeyDown += OnKeyDown;
|
||||
}
|
||||
|
||||
// ─── 단축키 항목 빌드 ─────────────────────────────────────────────────────
|
||||
|
||||
private static List<HelpItemModel> BuildShortcutItems(string globalHotkey = "Alt+Space")
|
||||
{
|
||||
var items = new List<HelpItemModel>();
|
||||
|
||||
// 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "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<HelpItemModel> sourceItems)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
_categories = new List<string> { 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<HelpItemModel> 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<HelpItemModel> filtered;
|
||||
|
||||
if (cat == CatAll)
|
||||
filtered = source;
|
||||
else if (cat == CatPopular)
|
||||
{
|
||||
var popularCmds = new HashSet<string>(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
|
||||
|
||||
Reference in New Issue
Block a user