From aa907d7b79460a13d20d7bd6c71bc161d3c3d657 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 20:51:26 +0900 Subject: [PATCH] =?UTF-8?q?[Phase46]=20=EB=8C=80=ED=98=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=ED=95=A0=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=202=EC=B0=A8=20=E2=80=94=2019=EA=B0=9C=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=ED=8C=8C=EC=85=9C=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 분할 대상 및 결과 ### AgentLoopService.cs (1,334줄 → 846줄) - AgentLoopService.HtmlReport.cs (151줄): AutoSaveAsHtml, ConvertTextToHtml, EscapeHtml - AgentLoopService.Verification.cs (349줄): 도구 분류 판별 + RunPostToolVerificationAsync + EmitEvent + CheckDecisionRequired + FormatToolCallSummary ### ChatWindow 분할 (8개 신규 파셜 파일) - ChatWindow.PlanViewer.cs (474줄): 계획 뷰어 — AddPlanningCard, AddDecisionButtons, CollapseDecisionButtons, ShowPlanButton 등 8개 메서드 - ChatWindow.EventBanner.cs (411줄): AddAgentEventBanner, BuildFileQuickActions - ChatWindow.TaskDecomposition.cs (1,170줄 → 307줄): RenderSuggestActionChips, BuildFeedbackContext, UpdateProgressBar, BuildDiffView 잔류 - ChatWindow.BottomBar.cs (345줄): BuildBottomBar, BuildCodeBottomBar, ShowLogLevelMenu, ShowLanguageMenu 등 - ChatWindow.MoodMenu.cs (456줄): ShowFormatMenu, ShowMoodMenu, ShowCustomMoodDialog 등 - ChatWindow.CustomPresets.cs (978줄 → 203줄): ShowCustomPresetDialog, SelectTopic 잔류 - ChatWindow.ConversationMenu.cs (255줄): ShowConversationMenu (카테고리/삭제/즐겨찾기 팝업) - ChatWindow.ConversationTitleEdit.cs (108줄): EnterTitleEditMode ### SettingsViewModel 분할 - SettingsViewModel.LlmProperties.cs (417줄): LLM·에이전트 관련 바인딩 프로퍼티 - SettingsViewModel.Properties.cs (837줄 → 427줄): 기능 토글·테마·스니펫 등 앱 수준 프로퍼티 ### TemplateService 분할 - TemplateService.Css.cs (559줄): 11종 CSS 테마 문자열 상수 - TemplateService.cs (734줄 → 179줄): 메서드 로직만 잔류 ### PlanViewerWindow 분할 - PlanViewerWindow.StepRenderer.cs (616줄): RenderSteps + SwapSteps + EditStep + 버튼 빌더 9개 - PlanViewerWindow.cs (931줄 → 324줄): Win32/생성자/공개 API 잔류 ### App.xaml.cs 분할 (776줄 → 452줄) - App.Settings.cs (252줄): SetupTrayIcon, OpenSettings, ToggleDockBar, RefreshDockBar, OpenAiChat - App.Helpers.cs (92줄): LoadAppIcon, IsAutoStartEnabled, SetAutoStart, OnExit ### LlmService.ToolUse.cs 분할 (719줄 → 115줄) - LlmService.ClaudeTools.cs (180줄): SendClaudeWithToolsAsync, BuildClaudeToolBody - LlmService.GeminiTools.cs (175줄): SendGeminiWithToolsAsync, BuildGeminiToolBody - LlmService.OpenAiTools.cs (215줄): SendOpenAiWithToolsAsync, BuildOpenAiToolBody ### SettingsWindow.UI.cs 분할 (802줄 → 310줄) - SettingsWindow.Storage.cs (167줄): RefreshStorageInfo, BtnStorageCleanup_Click 등 - SettingsWindow.HotkeyUI.cs (127줄): RefreshHotkeyBadges, EnsureHotkeyInCombo, GetKeyName 등 - SettingsWindow.DevMode.cs (90줄): DevModeCheckBox_Checked, UpdateDevModeContentVisibility ## 빌드 결과: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- src/AxCopilot/App.Helpers.cs | 92 ++ src/AxCopilot/App.Settings.cs | 252 +++++ src/AxCopilot/App.xaml.cs | 324 ------- .../Agent/AgentLoopService.HtmlReport.cs | 151 +++ .../Agent/AgentLoopService.Verification.cs | 349 +++++++ .../Services/Agent/AgentLoopService.cs | 488 ---------- .../Services/Agent/TemplateService.Css.cs | 559 ++++++++++++ .../Services/Agent/TemplateService.cs | 557 +---------- .../Services/LlmService.ClaudeTools.cs | 195 ++++ .../Services/LlmService.GeminiTools.cs | 190 ++++ .../Services/LlmService.OpenAiTools.cs | 251 +++++ src/AxCopilot/Services/LlmService.ToolUse.cs | 609 ------------ .../SettingsViewModel.LlmProperties.cs | 417 +++++++++ .../SettingsViewModel.Properties.cs | 410 --------- src/AxCopilot/Views/ChatWindow.BottomBar.cs | 345 +++++++ .../Views/ChatWindow.ConversationList.cs | 342 ------- .../Views/ChatWindow.ConversationMenu.cs | 255 ++++++ .../Views/ChatWindow.ConversationTitleEdit.cs | 108 +++ .../Views/ChatWindow.CustomPresets.cs | 775 ---------------- src/AxCopilot/Views/ChatWindow.EventBanner.cs | 411 +++++++++ src/AxCopilot/Views/ChatWindow.MoodMenu.cs | 456 +++++++++ src/AxCopilot/Views/ChatWindow.PlanViewer.cs | 474 ++++++++++ .../Views/ChatWindow.TaskDecomposition.cs | 863 ------------------ .../Views/PlanViewerWindow.StepRenderer.cs | 616 +++++++++++++ src/AxCopilot/Views/PlanViewerWindow.cs | 609 +----------- src/AxCopilot/Views/SettingsWindow.DevMode.cs | 97 ++ .../Views/SettingsWindow.HotkeyUI.cs | 139 +++ src/AxCopilot/Views/SettingsWindow.Storage.cs | 181 ++++ src/AxCopilot/Views/SettingsWindow.UI.cs | 390 -------- 29 files changed, 5540 insertions(+), 5365 deletions(-) create mode 100644 src/AxCopilot/App.Helpers.cs create mode 100644 src/AxCopilot/App.Settings.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopService.Verification.cs create mode 100644 src/AxCopilot/Services/Agent/TemplateService.Css.cs create mode 100644 src/AxCopilot/Services/LlmService.ClaudeTools.cs create mode 100644 src/AxCopilot/Services/LlmService.GeminiTools.cs create mode 100644 src/AxCopilot/Services/LlmService.OpenAiTools.cs create mode 100644 src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs create mode 100644 src/AxCopilot/Views/ChatWindow.BottomBar.cs create mode 100644 src/AxCopilot/Views/ChatWindow.ConversationMenu.cs create mode 100644 src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs create mode 100644 src/AxCopilot/Views/ChatWindow.EventBanner.cs create mode 100644 src/AxCopilot/Views/ChatWindow.MoodMenu.cs create mode 100644 src/AxCopilot/Views/ChatWindow.PlanViewer.cs create mode 100644 src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs create mode 100644 src/AxCopilot/Views/SettingsWindow.DevMode.cs create mode 100644 src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs create mode 100644 src/AxCopilot/Views/SettingsWindow.Storage.cs diff --git a/src/AxCopilot/App.Helpers.cs b/src/AxCopilot/App.Helpers.cs new file mode 100644 index 0000000..d22bb04 --- /dev/null +++ b/src/AxCopilot/App.Helpers.cs @@ -0,0 +1,92 @@ +using Microsoft.Win32; +using AxCopilot.Services; + +namespace AxCopilot; + +public partial class App +{ + // ─── 자동 시작 / 앱 종료 헬퍼 ────────────────────────────────────── + + private static System.Drawing.Icon LoadAppIcon() + { + // DPI 인식 아이콘 크기 (기본 16 → 고DPI에서 20/24/32) + var iconSize = System.Windows.Forms.SystemInformation.SmallIconSize; + + // 1) 파일 시스템에서 로드 (개발 환경) + try + { + var exeDir = System.IO.Path.GetDirectoryName(Environment.ProcessPath) + ?? AppContext.BaseDirectory; + var path = System.IO.Path.Combine(exeDir, "Assets", "icon.ico"); + if (System.IO.File.Exists(path)) + return new System.Drawing.Icon(path, iconSize); + } + catch (Exception) { } + + // 2) 내장 리소스에서 로드 (PublishSingleFile 배포) + try + { + var uri = new Uri("pack://application:,,,/Assets/icon.ico"); + var stream = System.Windows.Application.GetResourceStream(uri)?.Stream; + if (stream != null) + return new System.Drawing.Icon(stream, iconSize); + } + catch (Exception) { } + + return System.Drawing.SystemIcons.Application; + } + + private const string AutoRunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string AutoRunName = "AxCopilot"; + + private static bool IsAutoStartEnabled() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: false); + return key?.GetValue(AutoRunName) != null; + } + catch (Exception) { return false; } + } + + private static void SetAutoStart(bool enable) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: true); + if (key == null) return; + + if (enable) + { + var exePath = Environment.ProcessPath + ?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName + ?? string.Empty; + if (!string.IsNullOrEmpty(exePath)) + key.SetValue(AutoRunName, $"\"{exePath}\""); + } + else + { + key.DeleteValue(AutoRunName, throwOnMissingValue: false); + } + } + catch (Exception ex) + { + LogService.Warn($"자동 시작 레지스트리 설정 실패: {ex.Message}"); + } + } + + protected override void OnExit(System.Windows.ExitEventArgs e) + { + _chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기 + _inputListener?.Dispose(); + _clipboardHistory?.Dispose(); + _indexService?.Dispose(); + _sessionTracking?.Dispose(); + _worktimeReminder?.Dispose(); + _trayIcon?.Dispose(); + try { _singleInstanceMutex?.ReleaseMutex(); } catch (Exception) { } + _singleInstanceMutex?.Dispose(); + LogService.Info("=== AX Copilot 종료 ==="); + base.OnExit(e); + } +} diff --git a/src/AxCopilot/App.Settings.cs b/src/AxCopilot/App.Settings.cs new file mode 100644 index 0000000..68859bc --- /dev/null +++ b/src/AxCopilot/App.Settings.cs @@ -0,0 +1,252 @@ +using System.Windows; +using System.Windows.Forms; +using AxCopilot.Core; +using AxCopilot.Services; +using AxCopilot.ViewModels; +using AxCopilot.Views; + +namespace AxCopilot; + +public partial class App +{ + // ─── 설정창 / ChatWindow ───────────────────────────────────────────── + + private void SetupTrayIcon(PluginHost pluginHost, SettingsService settings) + { + _trayIcon = new NotifyIcon + { + Text = "AX Copilot", + Visible = true, + Icon = LoadAppIcon() + }; + + // ─── WPF 커스텀 트레이 메뉴 구성 ────────────────────────────────── + _trayMenu = new Views.TrayMenuWindow(); + _trayMenu + .AddItem("\uE7C5", "AX Commander 호출하기", () => + Dispatcher.Invoke(() => _launcher?.Show())) + .AddItem("\uE8BD", "AX Agent 대화하기", () => + Dispatcher.Invoke(() => OpenAiChat()), out var aiTrayItem) + .AddItem("\uE8A7", "독 바 표시", () => + Dispatcher.Invoke(() => ToggleDockBar())) + .AddSeparator() + .AddItem("\uE72C", "플러그인 재로드", () => + { + pluginHost.Reload(); + _trayIcon!.ShowBalloonTip(2000, "AX Copilot", "플러그인이 재로드되었습니다.", ToolTipIcon.None); + }) + .AddItem("\uE838", "로그 폴더 열기", () => + { + var logDir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "logs"); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", logDir) + { UseShellExecute = true }); + }) + .AddItem("\uE9D9", "사용 통계", () => + Dispatcher.Invoke(() => new StatisticsWindow().Show())) + .AddItem("\uE736", "사용 가이드 문서보기", () => + { + try { Dispatcher.Invoke(() => new Views.GuideViewerWindow().Show()); } + catch (Exception ex) { LogService.Error($"사용 가이드 열기 실패: {ex.Message}"); } + }) + .AddSeparator() + .AddToggleItem("\uE82F", "Windows 시작 시 자동 실행", IsAutoStartEnabled(), + isChecked => SetAutoStart(isChecked), out _, out _) + .AddSeparator() + .AddItem("\uE713", "설정", () => + Dispatcher.Invoke(OpenSettings)) + .AddItem("\uE946", "개발 정보", () => + Dispatcher.Invoke(() => new AboutWindow().Show())) + .AddItem("\uE711", "종료", () => + { + _inputListener?.Dispose(); + _trayIcon?.Dispose(); + Shutdown(); + }); + + // AI 기능 활성화 여부에 따라 메뉴 항목 가시성 동적 업데이트 + _trayMenu.Opening += () => + { + aiTrayItem.Visibility = settings.Settings.AiEnabled + ? System.Windows.Visibility.Visible + : System.Windows.Visibility.Collapsed; + }; + + // 우클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글 + _trayIcon.MouseClick += (_, e) => + { + if (e.Button == System.Windows.Forms.MouseButtons.Left) + Dispatcher.Invoke(() => _launcher?.Show()); + else if (e.Button == System.Windows.Forms.MouseButtons.Right) + Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate()); + }; + + // 타이머/알람 풍선 알림 서비스 연결 + NotificationService.Initialize((title, msg) => + { + Dispatcher.Invoke(() => + _trayIcon?.ShowBalloonTip(4000, title, msg, ToolTipIcon.None)); + }); + } + + /// ChatWindow 등 외부에서 설정 창을 여는 공개 메서드. + public void OpenSettingsFromChat() => Dispatcher.Invoke(OpenSettings); + + /// AX Agent 창 열기 (트레이 메뉴 등에서 호출). + private Views.ChatWindow? _chatWindow; + + /// + /// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출). + /// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다. + /// + internal void PrewarmChatWindow() + { + if (_chatWindow != null || _settings == null) return; + _chatWindow = new Views.ChatWindow(_settings); + } + + private void OpenAiChat() + { + if (_settings == null) return; + if (_chatWindow == null) + { + _chatWindow = new Views.ChatWindow(_settings); + } + _chatWindow.Show(); + _chatWindow.Activate(); + } + + public void ToggleDockBar() + { + if (_dockBar != null && _dockBar.IsVisible) + { + _dockBar.Hide(); + return; + } + + if (_dockBar == null) + { + _dockBar = new DockBarWindow(); + _dockBar.OnQuickSearch = query => + { + if (_launcher == null) return; + _launcher.Show(); + _launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시 + if (!string.IsNullOrEmpty(query)) + Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, + () => _launcher.SetInputText(query)); + }; + _dockBar.OnCapture = async () => + { + WindowTracker.Capture(); + if (_captureHandler != null) + await _captureHandler.CaptureDirectAsync("region"); + }; + _dockBar.OnOpenAgent = () => + { + if (_launcher == null) return; + _launcher.Show(); + Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, + () => _launcher.SetInputText("!")); + }; + } + var launcher = _settings?.Settings.Launcher; + var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" }; + _dockBar.BuildFromSettings(dockItems); + _dockBar.OnPositionChanged = (left, top) => + { + if (_settings != null) + { + _settings.Settings.Launcher.DockBarLeft = left; + _settings.Settings.Launcher.DockBarTop = top; + _settings.Save(); + } + }; + _dockBar.Show(); + _dockBar.ApplySettings( + launcher?.DockBarOpacity ?? 0.92, + launcher?.DockBarLeft ?? -1, + launcher?.DockBarTop ?? -1, + launcher?.DockBarRainbowGlow ?? false); + } + + /// 독 바를 현재 설정으로 즉시 새로고침합니다. + public void RefreshDockBar() + { + if (_dockBar == null || !_dockBar.IsVisible) return; + var launcher = _settings?.Settings.Launcher; + var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" }; + _dockBar.BuildFromSettings(dockItems); + _dockBar.ApplySettings( + launcher?.DockBarOpacity ?? 0.92, + launcher?.DockBarLeft ?? -1, + launcher?.DockBarTop ?? -1, + launcher?.DockBarRainbowGlow ?? false); + } + + private void OpenSettings() + { + if (_settingsWindow != null && _settingsWindow.IsVisible) + { + _settingsWindow.Activate(); + return; + } + + if (_settings == null || _launcher == null) return; + + var vm = new ViewModels.SettingsViewModel(_settings); + + // 미리보기 콜백: 현재 편집 중인 색상(vm.ColorRows)으로 런처에 즉시 반영 + void PreviewCallback(string themeKey) + { + if (themeKey == "custom") + { + var tempColors = new AxCopilot.Models.CustomThemeColors(); + foreach (var row in vm.ColorRows) + { + var prop = typeof(AxCopilot.Models.CustomThemeColors).GetProperty(row.Property); + prop?.SetValue(tempColors, row.Hex); + } + _launcher.ApplyTheme(themeKey, tempColors); + } + else + { + _launcher.ApplyTheme(themeKey, _settings.Settings.Launcher.CustomTheme); + } + } + + // 취소/X 닫기 콜백: 파일에 저장된 원본 설정으로 복원 + void RevertCallback() + { + _launcher.ApplyTheme( + _settings.Settings.Launcher.Theme ?? "system", + _settings.Settings.Launcher.CustomTheme); + } + + _settingsWindow = new Views.SettingsWindow(vm, PreviewCallback, RevertCallback) + { + // 핫키 녹화 중 글로벌 핫키 일시 정지 + SuspendHotkeyCallback = suspend => + { + if (_inputListener != null) + _inputListener.SuspendHotkey = suspend; + } + }; + + // 저장 완료 시 InputListener 핫키 갱신 + 알림 타이머 재시작 + vm.SaveCompleted += (_, _) => + { + if (_inputListener != null && _settings != null) + { + _inputListener.UpdateHotkey(_settings.Settings.Hotkey); + _inputListener.UpdateCaptureHotkey( + _settings.Settings.ScreenCapture.GlobalHotkey, + _settings.Settings.ScreenCapture.GlobalHotkeyEnabled); + } + _worktimeReminder?.RestartTimer(); + }; + + _settingsWindow.Show(); + } +} diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index c11e840..2f40050 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -449,328 +449,4 @@ public partial class App : System.Windows.Application ToolTipIcon.Warning); }); } - - private void SetupTrayIcon(PluginHost pluginHost, SettingsService settings) - { - _trayIcon = new NotifyIcon - { - Text = "AX Copilot", - Visible = true, - Icon = LoadAppIcon() - }; - - // ─── WPF 커스텀 트레이 메뉴 구성 ────────────────────────────────── - _trayMenu = new Views.TrayMenuWindow(); - _trayMenu - .AddItem("\uE7C5", "AX Commander 호출하기", () => - Dispatcher.Invoke(() => _launcher?.Show())) - .AddItem("\uE8BD", "AX Agent 대화하기", () => - Dispatcher.Invoke(() => OpenAiChat()), out var aiTrayItem) - .AddItem("\uE8A7", "독 바 표시", () => - Dispatcher.Invoke(() => ToggleDockBar())) - .AddSeparator() - .AddItem("\uE72C", "플러그인 재로드", () => - { - pluginHost.Reload(); - _trayIcon!.ShowBalloonTip(2000, "AX Copilot", "플러그인이 재로드되었습니다.", ToolTipIcon.None); - }) - .AddItem("\uE838", "로그 폴더 열기", () => - { - var logDir = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AxCopilot", "logs"); - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", logDir) - { UseShellExecute = true }); - }) - .AddItem("\uE9D9", "사용 통계", () => - Dispatcher.Invoke(() => new StatisticsWindow().Show())) - .AddItem("\uE736", "사용 가이드 문서보기", () => - { - try { Dispatcher.Invoke(() => new Views.GuideViewerWindow().Show()); } - catch (Exception ex) { LogService.Error($"사용 가이드 열기 실패: {ex.Message}"); } - }) - .AddSeparator() - .AddToggleItem("\uE82F", "Windows 시작 시 자동 실행", IsAutoStartEnabled(), - isChecked => SetAutoStart(isChecked), out _, out _) - .AddSeparator() - .AddItem("\uE713", "설정", () => - Dispatcher.Invoke(OpenSettings)) - .AddItem("\uE946", "개발 정보", () => - Dispatcher.Invoke(() => new AboutWindow().Show())) - .AddItem("\uE711", "종료", () => - { - _inputListener?.Dispose(); - _trayIcon?.Dispose(); - Shutdown(); - }); - - // AI 기능 활성화 여부에 따라 메뉴 항목 가시성 동적 업데이트 - _trayMenu.Opening += () => - { - aiTrayItem.Visibility = settings.Settings.AiEnabled - ? System.Windows.Visibility.Visible - : System.Windows.Visibility.Collapsed; - }; - - // 우클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글 - _trayIcon.MouseClick += (_, e) => - { - if (e.Button == System.Windows.Forms.MouseButtons.Left) - Dispatcher.Invoke(() => _launcher?.Show()); - else if (e.Button == System.Windows.Forms.MouseButtons.Right) - Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate()); - }; - - // 타이머/알람 풍선 알림 서비스 연결 - NotificationService.Initialize((title, msg) => - { - Dispatcher.Invoke(() => - _trayIcon?.ShowBalloonTip(4000, title, msg, ToolTipIcon.None)); - }); - } - - /// ChatWindow 등 외부에서 설정 창을 여는 공개 메서드. - public void OpenSettingsFromChat() => Dispatcher.Invoke(OpenSettings); - - /// AX Agent 창 열기 (트레이 메뉴 등에서 호출). - private Views.ChatWindow? _chatWindow; - - /// - /// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출). - /// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다. - /// - internal void PrewarmChatWindow() - { - if (_chatWindow != null || _settings == null) return; - _chatWindow = new Views.ChatWindow(_settings); - } - - private void OpenAiChat() - { - if (_settings == null) return; - if (_chatWindow == null) - { - _chatWindow = new Views.ChatWindow(_settings); - } - _chatWindow.Show(); - _chatWindow.Activate(); - } - - public void ToggleDockBar() - { - if (_dockBar != null && _dockBar.IsVisible) - { - _dockBar.Hide(); - return; - } - - if (_dockBar == null) - { - _dockBar = new DockBarWindow(); - _dockBar.OnQuickSearch = query => - { - if (_launcher == null) return; - _launcher.Show(); - _launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시 - if (!string.IsNullOrEmpty(query)) - Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, - () => _launcher.SetInputText(query)); - }; - _dockBar.OnCapture = async () => - { - WindowTracker.Capture(); - if (_captureHandler != null) - await _captureHandler.CaptureDirectAsync("region"); - }; - _dockBar.OnOpenAgent = () => - { - if (_launcher == null) return; - _launcher.Show(); - Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, - () => _launcher.SetInputText("!")); - }; - } - var launcher = _settings?.Settings.Launcher; - var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" }; - _dockBar.BuildFromSettings(dockItems); - _dockBar.OnPositionChanged = (left, top) => - { - if (_settings != null) - { - _settings.Settings.Launcher.DockBarLeft = left; - _settings.Settings.Launcher.DockBarTop = top; - _settings.Save(); - } - }; - _dockBar.Show(); - _dockBar.ApplySettings( - launcher?.DockBarOpacity ?? 0.92, - launcher?.DockBarLeft ?? -1, - launcher?.DockBarTop ?? -1, - launcher?.DockBarRainbowGlow ?? false); - } - - /// 독 바를 현재 설정으로 즉시 새로고침합니다. - public void RefreshDockBar() - { - if (_dockBar == null || !_dockBar.IsVisible) return; - var launcher = _settings?.Settings.Launcher; - var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" }; - _dockBar.BuildFromSettings(dockItems); - _dockBar.ApplySettings( - launcher?.DockBarOpacity ?? 0.92, - launcher?.DockBarLeft ?? -1, - launcher?.DockBarTop ?? -1, - launcher?.DockBarRainbowGlow ?? false); - } - - private void OpenSettings() - { - if (_settingsWindow != null && _settingsWindow.IsVisible) - { - _settingsWindow.Activate(); - return; - } - - if (_settings == null || _launcher == null) return; - - var vm = new ViewModels.SettingsViewModel(_settings); - - // 미리보기 콜백: 현재 편집 중인 색상(vm.ColorRows)으로 런처에 즉시 반영 - void PreviewCallback(string themeKey) - { - if (themeKey == "custom") - { - var tempColors = new AxCopilot.Models.CustomThemeColors(); - foreach (var row in vm.ColorRows) - { - var prop = typeof(AxCopilot.Models.CustomThemeColors).GetProperty(row.Property); - prop?.SetValue(tempColors, row.Hex); - } - _launcher.ApplyTheme(themeKey, tempColors); - } - else - { - _launcher.ApplyTheme(themeKey, _settings.Settings.Launcher.CustomTheme); - } - } - - // 취소/X 닫기 콜백: 파일에 저장된 원본 설정으로 복원 - void RevertCallback() - { - _launcher.ApplyTheme( - _settings.Settings.Launcher.Theme ?? "system", - _settings.Settings.Launcher.CustomTheme); - } - - _settingsWindow = new Views.SettingsWindow(vm, PreviewCallback, RevertCallback) - { - // 핫키 녹화 중 글로벌 핫키 일시 정지 - SuspendHotkeyCallback = suspend => - { - if (_inputListener != null) - _inputListener.SuspendHotkey = suspend; - } - }; - - // 저장 완료 시 InputListener 핫키 갱신 + 알림 타이머 재시작 - vm.SaveCompleted += (_, _) => - { - if (_inputListener != null && _settings != null) - { - _inputListener.UpdateHotkey(_settings.Settings.Hotkey); - _inputListener.UpdateCaptureHotkey( - _settings.Settings.ScreenCapture.GlobalHotkey, - _settings.Settings.ScreenCapture.GlobalHotkeyEnabled); - } - _worktimeReminder?.RestartTimer(); - }; - - _settingsWindow.Show(); - } - - // ─── 자동 시작 레지스트리 헬퍼 ────────────────────────────────────────── - - private static System.Drawing.Icon LoadAppIcon() - { - // DPI 인식 아이콘 크기 (기본 16 → 고DPI에서 20/24/32) - var iconSize = System.Windows.Forms.SystemInformation.SmallIconSize; - - // 1) 파일 시스템에서 로드 (개발 환경) - try - { - var exeDir = System.IO.Path.GetDirectoryName(Environment.ProcessPath) - ?? AppContext.BaseDirectory; - var path = System.IO.Path.Combine(exeDir, "Assets", "icon.ico"); - if (System.IO.File.Exists(path)) - return new System.Drawing.Icon(path, iconSize); - } - catch (Exception) { } - - // 2) 내장 리소스에서 로드 (PublishSingleFile 배포) - try - { - var uri = new Uri("pack://application:,,,/Assets/icon.ico"); - var stream = System.Windows.Application.GetResourceStream(uri)?.Stream; - if (stream != null) - return new System.Drawing.Icon(stream, iconSize); - } - catch (Exception) { } - - return System.Drawing.SystemIcons.Application; - } - - private const string AutoRunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; - private const string AutoRunName = "AxCopilot"; - - private static bool IsAutoStartEnabled() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: false); - return key?.GetValue(AutoRunName) != null; - } - catch (Exception) { return false; } - } - - private static void SetAutoStart(bool enable) - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: true); - if (key == null) return; - - if (enable) - { - var exePath = Environment.ProcessPath - ?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName - ?? string.Empty; - if (!string.IsNullOrEmpty(exePath)) - key.SetValue(AutoRunName, $"\"{exePath}\""); - } - else - { - key.DeleteValue(AutoRunName, throwOnMissingValue: false); - } - } - catch (Exception ex) - { - LogService.Warn($"자동 시작 레지스트리 설정 실패: {ex.Message}"); - } - } - - protected override void OnExit(ExitEventArgs e) - { - _chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기 - _inputListener?.Dispose(); - _clipboardHistory?.Dispose(); - _indexService?.Dispose(); - _sessionTracking?.Dispose(); - _worktimeReminder?.Dispose(); - _trayIcon?.Dispose(); - try { _singleInstanceMutex?.ReleaseMutex(); } catch (Exception) { } - _singleInstanceMutex?.Dispose(); - LogService.Info("=== AX Copilot 종료 ==="); - base.OnExit(e); - } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs b/src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs new file mode 100644 index 0000000..de5beca --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs @@ -0,0 +1,151 @@ +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + /// LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다. + private string? AutoSaveAsHtml(string textContent, string userQuery, AgentContext context) + { + try + { + // 파일명 생성 — 동사/명령어를 제거하여 깔끔한 파일명 만들기 + var title = userQuery.Length > Defaults.QueryTitleMaxLength ? userQuery[..Defaults.QueryTitleMaxLength] : userQuery; + // 파일명에 불필요한 동사/명령어 제거 + var removeWords = new[] { "작성해줘", "작성해 줘", "만들어줘", "만들어 줘", "써줘", "써 줘", + "생성해줘", "생성해 줘", "작성해", "만들어", "생성해", "해줘", "해 줘", "부탁해" }; + var safeTitle = title; + foreach (var w in removeWords) + safeTitle = safeTitle.Replace(w, "", StringComparison.OrdinalIgnoreCase); + foreach (var c in System.IO.Path.GetInvalidFileNameChars()) + safeTitle = safeTitle.Replace(c, '_'); + safeTitle = safeTitle.Trim().TrimEnd('.').Trim(); + + var fileName = $"{safeTitle}.html"; + var fullPath = FileReadTool.ResolvePath(fileName, context.WorkFolder); + if (context.ActiveTab == "Cowork") + fullPath = AgentContext.EnsureTimestampedPath(fullPath); + + var dir = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir); + + // 텍스트 → HTML 변환 + var css = TemplateService.GetCss("professional"); + var htmlBody = ConvertTextToHtml(textContent); + + var html = $@" + + + +{EscapeHtml(title)} + + + +
+

{EscapeHtml(title)}

+
작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성
+{htmlBody} +
+ +"; + + System.IO.File.WriteAllText(fullPath, html, System.Text.Encoding.UTF8); + LogService.Info($"[AgentLoop] 문서 자동 저장 완료: {fullPath}"); + return fullPath; + } + catch (Exception ex) + { + LogService.Warn($"[AgentLoop] 문서 자동 저장 실패: {ex.Message}"); + return null; + } + } + + /// LLM 텍스트(마크다운 형식)를 HTML로 변환합니다. + private static string ConvertTextToHtml(string text) + { + var sb = new System.Text.StringBuilder(); + var lines = text.Split('\n'); + var inList = false; + var listType = "ul"; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + + // 빈 줄 + if (string.IsNullOrWhiteSpace(line)) + { + if (inList) { sb.AppendLine($""); inList = false; } + continue; + } + + // 마크다운 제목 + if (line.StartsWith("### ")) + { + if (inList) { sb.AppendLine($""); inList = false; } + sb.AppendLine($"

{EscapeHtml(line[4..])}

"); + continue; + } + if (line.StartsWith("## ")) + { + if (inList) { sb.AppendLine($""); inList = false; } + sb.AppendLine($"

{EscapeHtml(line[3..])}

"); + continue; + } + if (line.StartsWith("# ")) + { + if (inList) { sb.AppendLine($""); inList = false; } + sb.AppendLine($"

{EscapeHtml(line[2..])}

"); + continue; + } + + // 번호 리스트 (1. 2. 등) - 대제목급이면 h2로 + if (System.Text.RegularExpressions.Regex.IsMatch(line, @"^\d+\.\s+\S")) + { + var content = System.Text.RegularExpressions.Regex.Replace(line, @"^\d+\.\s+", ""); + // 짧고 제목 같으면 h2, 길면 리스트 + if (content.Length < 80 && !content.Contains('.') && !line.StartsWith(" ")) + { + if (inList) { sb.AppendLine($""); inList = false; } + sb.AppendLine($"

{EscapeHtml(line)}

"); + } + else + { + if (!inList) { sb.AppendLine("
    "); inList = true; listType = "ol"; } + sb.AppendLine($"
  1. {EscapeHtml(content)}
  2. "); + } + continue; + } + + // 불릿 리스트 + if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• ")) + { + var content = line.TrimStart()[2..].Trim(); + if (!inList) { sb.AppendLine("
      "); inList = true; listType = "ul"; } + sb.AppendLine($"
    • {EscapeHtml(content)}
    • "); + continue; + } + + // 일반 텍스트 + if (inList) { sb.AppendLine($""); inList = false; } + sb.AppendLine($"

      {EscapeHtml(line)}

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

      {EscapeHtml(title)}

      -
      작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성
      -{htmlBody} -
      - -"; - - System.IO.File.WriteAllText(fullPath, html, System.Text.Encoding.UTF8); - LogService.Info($"[AgentLoop] 문서 자동 저장 완료: {fullPath}"); - return fullPath; - } - catch (Exception ex) - { - LogService.Warn($"[AgentLoop] 문서 자동 저장 실패: {ex.Message}"); - return null; - } - } - - /// LLM 텍스트(마크다운 형식)를 HTML로 변환합니다. - private static string ConvertTextToHtml(string text) - { - var sb = new System.Text.StringBuilder(); - var lines = text.Split('\n'); - var inList = false; - var listType = "ul"; - - foreach (var rawLine in lines) - { - var line = rawLine.TrimEnd(); - - // 빈 줄 - if (string.IsNullOrWhiteSpace(line)) - { - if (inList) { sb.AppendLine($""); inList = false; } - continue; - } - - // 마크다운 제목 - if (line.StartsWith("### ")) - { - if (inList) { sb.AppendLine($""); inList = false; } - sb.AppendLine($"

      {EscapeHtml(line[4..])}

      "); - continue; - } - if (line.StartsWith("## ")) - { - if (inList) { sb.AppendLine($""); inList = false; } - sb.AppendLine($"

      {EscapeHtml(line[3..])}

      "); - continue; - } - if (line.StartsWith("# ")) - { - if (inList) { sb.AppendLine($""); inList = false; } - sb.AppendLine($"

      {EscapeHtml(line[2..])}

      "); - continue; - } - - // 번호 리스트 (1. 2. 등) - 대제목급이면 h2로 - if (System.Text.RegularExpressions.Regex.IsMatch(line, @"^\d+\.\s+\S")) - { - var content = System.Text.RegularExpressions.Regex.Replace(line, @"^\d+\.\s+", ""); - // 짧고 제목 같으면 h2, 길면 리스트 - if (content.Length < 80 && !content.Contains('.') && !line.StartsWith(" ")) - { - if (inList) { sb.AppendLine($""); inList = false; } - sb.AppendLine($"

      {EscapeHtml(line)}

      "); - } - else - { - if (!inList) { sb.AppendLine("
        "); inList = true; listType = "ol"; } - sb.AppendLine($"
      1. {EscapeHtml(content)}
      2. "); - } - continue; - } - - // 불릿 리스트 - if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• ")) - { - var content = line.TrimStart()[2..].Trim(); - if (!inList) { sb.AppendLine("
          "); inList = true; listType = "ul"; } - sb.AppendLine($"
        • {EscapeHtml(content)}
        • "); - continue; - } - - // 일반 텍스트 - if (inList) { sb.AppendLine($""); inList = false; } - sb.AppendLine($"

          {EscapeHtml(line)}

          "); - } - - if (inList) sb.AppendLine($""); - return sb.ToString(); - } - - private static string EscapeHtml(string text) - => text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); - - /// 사용자 요청이 문서/보고서 생성인지 판단합니다. - private static bool IsDocumentCreationRequest(string query) - { - if (string.IsNullOrWhiteSpace(query)) return false; - // 문서 생성 관련 키워드 패턴 - var keywords = new[] - { - "보고서", "리포트", "report", "문서", "작성해", "써줘", "써 줘", "만들어", - "분석서", "제안서", "기획서", "회의록", "매뉴얼", "가이드", - "excel", "엑셀", "docx", "word", "html", "pptx", "ppt", - "프레젠테이션", "발표자료", "슬라이드" - }; - var q = query.ToLowerInvariant(); - return keywords.Any(k => q.Contains(k, StringComparison.OrdinalIgnoreCase)); - } - - /// 문서 생성 도구인지 확인합니다 (Cowork 검증 대상). - private static bool IsDocumentCreationTool(string toolName) - { - return toolName is "file_write" or "docx_create" or "html_create" - or "excel_create" or "csv_create" or "script_create" or "pptx_create"; - } - - /// - /// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다. - /// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다. - /// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다. - /// - private static bool IsTerminalDocumentTool(string toolName) - { - return toolName is "html_create" or "docx_create" or "excel_create" - or "pptx_create" or "document_assemble" or "csv_create"; - } - - /// 코드 생성/수정 도구인지 확인합니다 (Code 검증 대상). - private static bool IsCodeVerificationTarget(string toolName) - { - return toolName is "file_write" or "file_edit" or "script_create" - or "process"; // 빌드/테스트 실행 결과 검증 - } - - /// - /// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다. - /// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다. - /// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다. - /// - /// 읽기 전용 검증 도구 목록 (file_read만 허용) - private static readonly HashSet VerificationAllowedTools = ["file_read", "directory_list"]; - - private async Task RunPostToolVerificationAsync( - List messages, string toolName, ToolResult result, - AgentContext context, CancellationToken ct) - { - EmitEvent(AgentEventType.Thinking, "", "🔍 생성 결과물 검증 중..."); - - // 생성된 파일 경로 추출 - var filePath = result.FilePath ?? ""; - var fileRef = string.IsNullOrEmpty(filePath) ? "방금 생성한 결과물" : $"파일 '{filePath}'"; - - // 탭별 검증 프롬프트 생성 — 읽기 + 보고만 (수정 금지) - var isCodeTab = context.ActiveTab == "Code"; - var checkList = isCodeTab - ? " - 구문 오류가 없는가?\n" + - " - 참조하는 클래스/메서드/변수가 존재하는가?\n" + - " - 코딩 컨벤션이 일관적인가?\n" + - " - 에지 케이스 처리가 누락되지 않았는가?" - : " - 사용자 요청에 맞는 내용이 모두 포함되었는가?\n" + - " - 구조와 형식이 올바른가?\n" + - " - 누락된 섹션이나 불완전한 내용이 없는가?\n" + - " - 한국어 맞춤법/표현이 자연스러운가?"; - - var verificationPrompt = new ChatMessage - { - Role = "user", - Content = $"[System:Verification] {fileRef}을 검증하세요.\n" + - "1. file_read 도구로 생성된 파일의 내용을 읽으세요.\n" + - "2. 다음 항목을 확인하세요:\n" + - checkList + "\n" + - "3. 결과를 간단히 보고하세요. 문제가 있으면 구체적으로 무엇이 잘못되었는지 설명하세요.\n" + - "⚠️ 중요: 이 단계에서는 파일을 직접 수정하지 마세요. 보고만 하세요." - }; - - // 검증 메시지를 임시로 추가 (검증 완료 후 전부 제거) - var insertIndex = messages.Count; - messages.Add(verificationPrompt); - var addedMessages = new List { verificationPrompt }; - - try - { - // 읽기 전용 도구만 제공 (file_write, file_edit 등 쓰기 도구 차단) - var allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools); - var readOnlyTools = allTools - .Where(t => VerificationAllowedTools.Contains(t.Name)) - .ToList(); - - var verifyBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct); - - // 검증 응답 처리 - var verifyText = new List(); - var verifyToolCalls = new List(); - - foreach (var block in verifyBlocks) - { - if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) - verifyText.Add(block.Text); - else if (block.Type == "tool_use") - verifyToolCalls.Add(block); - } - - var verifyResponse = string.Join("\n", verifyText); - - // file_read 도구 호출 처리 (읽기만 허용) - if (verifyToolCalls.Count > 0) - { - var contentBlocks = new List(); - if (!string.IsNullOrEmpty(verifyResponse)) - contentBlocks.Add(new { type = "text", text = verifyResponse }); - foreach (var tc in verifyToolCalls) - contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); - var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); - var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent }; - messages.Add(assistantMsg); - addedMessages.Add(assistantMsg); - - foreach (var tc in verifyToolCalls) - { - var tool = _tools.Get(tc.ToolName); - if (tool == null) - { - var errMsg = LlmService.CreateToolResultMessage(tc.ToolId, tc.ToolName, "검증 단계에서는 읽기 도구만 사용 가능합니다."); - messages.Add(errMsg); - addedMessages.Add(errMsg); - continue; - } - - EmitEvent(AgentEventType.ToolCall, tc.ToolName, $"[검증] {FormatToolCallSummary(tc)}"); - try - { - var input = tc.ToolInput ?? System.Text.Json.JsonDocument.Parse("{}").RootElement; - var verifyResult = await tool.ExecuteAsync(input, context, ct); - var toolMsg = LlmService.CreateToolResultMessage( - tc.ToolId, tc.ToolName, TruncateOutput(verifyResult.Output, Defaults.ToolResultTruncateLength)); - messages.Add(toolMsg); - addedMessages.Add(toolMsg); - } - catch (Exception ex) - { - var errMsg = LlmService.CreateToolResultMessage( - tc.ToolId, tc.ToolName, $"검증 도구 실행 오류: {ex.Message}"); - messages.Add(errMsg); - addedMessages.Add(errMsg); - } - } - - // file_read 결과를 받은 후 최종 검증 판단을 받기 위해 한 번 더 호출 - var finalBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct); - verifyResponse = string.Join("\n", - finalBlocks.Where(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text)).Select(b => b.Text)); - } - - // 검증 결과를 이벤트로 표시 - if (!string.IsNullOrEmpty(verifyResponse)) - { - var summary = verifyResponse.Length > Defaults.VerificationSummaryMaxLength ? verifyResponse[..Defaults.VerificationSummaryMaxLength] + "…" : verifyResponse; - EmitEvent(AgentEventType.Thinking, "", $"✅ 검증 결과: {summary}"); - - // 문제가 발견된 경우: 검증 보고서를 컨텍스트에 남겨서 다음 루프에서 자연스럽게 수정 - var hasIssues = verifyResponse.Contains("문제") || verifyResponse.Contains("수정") || - verifyResponse.Contains("누락") || verifyResponse.Contains("오류") || - verifyResponse.Contains("잘못") || verifyResponse.Contains("부족"); - if (hasIssues) - { - // 검증 관련 임시 메시지를 모두 제거 - foreach (var msg in addedMessages) - messages.Remove(msg); - - // 검증 보고서만 간결하게 남기기 (다음 루프에서 LLM이 자연스럽게 수정) - messages.Add(new ChatMessage - { - Role = "user", - Content = $"[System] 방금 생성한 {fileRef}에 대한 자동 검증 결과, 다음 문제가 발견되었습니다:\n{verifyResponse}\n\n위 문제를 수정해 주세요." - }); - return; - } - } - } - catch (Exception ex) - { - EmitEvent(AgentEventType.Error, "", $"검증 LLM 호출 실패: {ex.Message}"); - } - - // 검증 통과 또는 실패: 임시 메시지 전부 제거 (컨텍스트 오염 방지) - foreach (var msg in addedMessages) - messages.Remove(msg); - } - private AgentContext BuildContext(string? tabOverride = null) { var llm = _settings.Settings.Llm; @@ -1186,149 +843,4 @@ public partial class AgentLoopService DevModeStepApproval = llm.DevModeStepApproval, }; } - - private void EmitEvent(AgentEventType type, string toolName, string summary, - string? filePath = null, int stepCurrent = 0, int stepTotal = 0, List? steps = null, - long elapsedMs = 0, int inputTokens = 0, int outputTokens = 0, - string? toolInput = null, int iteration = 0) - { - // AgentLogLevel에 따라 이벤트 필터링 - var logLevel = _settings.Settings.Llm.AgentLogLevel; - - // simple: ToolCall, ToolResult, Error, Complete, StepStart, StepDone, Decision만 - if (logLevel == "simple" && type is AgentEventType.Thinking or AgentEventType.Planning) - return; - - // simple: Summary 200자 제한 - if (logLevel == "simple" && summary.Length > 200) - summary = summary[..200] + "…"; - - // debug 아닌 경우 ToolInput 제거 - if (logLevel != "debug") - toolInput = null; - - var evt = new AgentEvent - { - Type = type, - ToolName = toolName, - Summary = summary, - FilePath = filePath, - Success = type != AgentEventType.Error, - StepCurrent = stepCurrent, - StepTotal = stepTotal, - Steps = steps, - ElapsedMs = elapsedMs, - InputTokens = inputTokens, - OutputTokens = outputTokens, - ToolInput = toolInput, - Iteration = iteration, - }; - - if (Dispatcher != null) - Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); }); - else - { - Events.Add(evt); - EventOccurred?.Invoke(evt); - } - } - - /// 영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null. - private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) - { - var level = _settings.Settings.Llm.AgentDecisionLevel ?? "normal"; - var toolName = call.ToolName ?? ""; - var input = call.ToolInput; - - // Git 커밋 — 수준에 관계없이 무조건 확인 - if (toolName == "git_tool") - { - var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : ""; - if (action == "commit") - { - var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : ""; - return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}"; - } - } - - // minimal: 파일 삭제, 외부 명령만 - if (level == "minimal") - { - // process 도구 (외부 명령 실행) - if (toolName == "process") - { - var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : ""; - return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}"; - } - return null; - } - - // normal: + 새 파일 생성, 여러 파일 수정, 문서 생성, 외부 명령 - if (level == "normal" || level == "detailed") - { - // 외부 명령 실행 - if (toolName == "process") - { - var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : ""; - return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}"; - } - - // 새 파일 생성 - if (toolName == "file_write") - { - var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; - if (!string.IsNullOrEmpty(path)) - { - var fullPath = System.IO.Path.IsPathRooted(path) ? path - : System.IO.Path.Combine(context.WorkFolder, path ?? ""); - if (!System.IO.File.Exists(fullPath)) - return $"새 파일을 생성하시겠습니까?\n\n경로: {path}"; - } - } - - // 문서 생성 (Excel, Word, HTML 등) - if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create") - { - var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; - return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}"; - } - - // 빌드/테스트 실행 - if (toolName is "build_run" or "test_loop") - { - var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : ""; - return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}"; - } - } - - // detailed: 모든 파일 수정 - if (level == "detailed") - { - if (toolName is "file_write" or "file_edit") - { - var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; - return $"파일을 수정하시겠습니까?\n\n경로: {path}"; - } - } - - return null; - } - - private static string FormatToolCallSummary(LlmService.ContentBlock call) - { - if (call.ToolInput == null) return call.ToolName; - try - { - // 주요 파라미터만 표시 - var input = call.ToolInput.Value; - if (input.TryGetProperty("path", out var path)) - return $"{call.ToolName}: {path.GetString()}"; - if (input.TryGetProperty("command", out var cmd)) - return $"{call.ToolName}: {cmd.GetString()}"; - if (input.TryGetProperty("pattern", out var pat)) - return $"{call.ToolName}: {pat.GetString()}"; - return call.ToolName; - } - catch (Exception) { return call.ToolName; } - } } diff --git a/src/AxCopilot/Services/Agent/TemplateService.Css.cs b/src/AxCopilot/Services/Agent/TemplateService.Css.cs new file mode 100644 index 0000000..641003a --- /dev/null +++ b/src/AxCopilot/Services/Agent/TemplateService.Css.cs @@ -0,0 +1,559 @@ +namespace AxCopilot.Services.Agent; + +public static partial class TemplateService +{ + // ════════════════════════════════════════════════════════════════════ + // CSS 템플릿 정의 + // ════════════════════════════════════════════════════════════════════ + + #region Modern — 현대적 + private const string CssModern = """ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; + background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; } + .container { max-width: 880px; margin: 0 auto; background: #fff; + border-radius: 16px; padding: 56px 52px; + box-shadow: 0 4px 24px rgba(0,0,0,0.06); } + h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; } + h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #1d1d1f; + padding-bottom: 8px; border-bottom: 2px solid #e5e5ea; } + h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #0071e3; } + .meta { font-size: 12px; color: #86868b; margin-bottom: 28px; letter-spacing: 0.3px; } + p { margin: 10px 0; font-size: 14.5px; } + table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px; + border-radius: 10px; overflow: hidden; } + th { background: #f5f5f7; text-align: left; padding: 12px 14px; font-weight: 600; + color: #1d1d1f; border-bottom: 2px solid #d2d2d7; } + td { padding: 10px 14px; border-bottom: 1px solid #f0f0f2; } + tr:hover td { background: #f9f9fb; } + ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } + li { margin: 5px 0; } + code { background: #f5f5f7; padding: 2px 8px; border-radius: 6px; font-size: 13px; + font-family: 'SF Mono', Consolas, monospace; color: #e3116c; } + pre { background: #1d1d1f; color: #f5f5f7; padding: 20px; border-radius: 12px; + overflow-x: auto; font-size: 13px; margin: 16px 0; line-height: 1.6; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 3px solid #0071e3; padding: 12px 20px; margin: 16px 0; + background: #f0f7ff; color: #1d1d1f; border-radius: 0 8px 8px 0; font-size: 14px; } + .highlight { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%); + padding: 16px 20px; border-radius: 10px; margin: 16px 0; } + .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; + font-weight: 600; background: #0071e3; color: #fff; margin: 2px 4px 2px 0; } + """; + #endregion + + #region Professional — 전문가 + private const string CssProfessional = """ + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; + background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; } + .container { max-width: 900px; margin: 0 auto; background: #fff; + border-radius: 8px; padding: 48px; + box-shadow: 0 1px 8px rgba(0,0,0,0.08); + border-top: 4px solid #1e3a5f; } + h1 { font-size: 26px; font-weight: 700; color: #1e3a5f; margin-bottom: 4px; } + h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; color: #1e3a5f; + border-bottom: 2px solid #c8d6e5; padding-bottom: 6px; } + h3 { font-size: 15px; font-weight: 600; margin: 22px 0 8px; color: #2c5282; } + .meta { font-size: 12px; color: #94a3b8; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0; + padding-bottom: 12px; } + p { margin: 8px 0; font-size: 14px; } + table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px; + border: 1px solid #e2e8f0; } + th { background: #1e3a5f; color: #fff; text-align: left; padding: 10px 14px; + font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.5px; } + td { padding: 9px 14px; border-bottom: 1px solid #e2e8f0; } + tr:nth-child(even) td { background: #f8fafc; } + tr:hover td { background: #eef2ff; } + ul, ol { margin: 8px 0 8px 24px; } + li { margin: 4px 0; font-size: 14px; } + code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12.5px; + font-family: Consolas, monospace; color: #1e3a5f; } + pre { background: #0f172a; color: #e2e8f0; padding: 18px; border-radius: 6px; + overflow-x: auto; font-size: 12.5px; margin: 14px 0; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 4px solid #1e3a5f; padding: 10px 18px; margin: 14px 0; + background: #f0f4f8; color: #334155; } + .callout { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px; + padding: 14px 18px; margin: 14px 0; font-size: 13.5px; } + .callout strong { color: #1e40af; } + """; + #endregion + + #region Creative — 아이디어 + private const string CssCreative = """ + @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; } + .container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95); + backdrop-filter: blur(20px); border-radius: 20px; padding: 52px; + box-shadow: 0 20px 60px rgba(0,0,0,0.15); } + h1 { font-size: 30px; font-weight: 700; + background: linear-gradient(135deg, #667eea, #e040fb); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + margin-bottom: 4px; } + h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #553c9a; + position: relative; padding-left: 16px; } + h2::before { content: ''; position: absolute; left: 0; top: 4px; width: 4px; height: 22px; + background: linear-gradient(180deg, #667eea, #e040fb); border-radius: 4px; } + h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: #7c3aed; } + .meta { font-size: 12px; color: #a0aec0; margin-bottom: 28px; } + p { margin: 10px 0; font-size: 14.5px; } + table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0; + font-size: 13.5px; border-radius: 12px; overflow: hidden; + box-shadow: 0 4px 12px rgba(102,126,234,0.1); } + th { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; + text-align: left; padding: 12px 14px; font-weight: 600; } + td { padding: 10px 14px; border-bottom: 1px solid #f0e7fe; } + tr:hover td { background: #faf5ff; } + ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } + li { margin: 5px 0; } + li::marker { color: #7c3aed; } + code { background: #f5f3ff; padding: 2px 8px; border-radius: 6px; font-size: 13px; + font-family: 'Fira Code', Consolas, monospace; color: #7c3aed; } + pre { background: #1a1a2e; color: #e0d4f5; padding: 20px; border-radius: 14px; + overflow-x: auto; font-size: 13px; margin: 16px 0; + border: 1px solid rgba(124,58,237,0.2); } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 4px solid #7c3aed; padding: 14px 20px; margin: 16px 0; + background: linear-gradient(135deg, #f5f3ff, #faf5ff); + border-radius: 0 12px 12px 0; font-style: italic; } + .card { background: #fff; border: 1px solid #e9d8fd; border-radius: 14px; + padding: 20px; margin: 14px 0; box-shadow: 0 2px 8px rgba(124,58,237,0.08); } + .tag { display: inline-block; padding: 3px 12px; border-radius: 20px; font-size: 11px; + font-weight: 500; background: linear-gradient(135deg, #667eea, #764ba2); + color: #fff; margin: 2px 4px 2px 0; } + """; + #endregion + + #region Minimal — 미니멀 + private const string CssMinimal = """ + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Georgia', 'Batang', serif; + background: #fff; color: #222; line-height: 1.85; padding: 60px 24px; } + .container { max-width: 720px; margin: 0 auto; padding: 0; } + h1 { font-size: 32px; font-weight: 400; color: #000; margin-bottom: 4px; + letter-spacing: -0.5px; } + h2 { font-size: 20px; font-weight: 400; margin: 40px 0 14px; color: #000; + border-bottom: 1px solid #ddd; padding-bottom: 8px; } + h3 { font-size: 16px; font-weight: 600; margin: 28px 0 10px; color: #333; } + .meta { font-size: 12px; color: #999; margin-bottom: 36px; font-style: italic; } + p { margin: 12px 0; font-size: 15px; text-align: justify; } + table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 14px; } + th { text-align: left; padding: 8px 0; font-weight: 600; border-bottom: 2px solid #000; + font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #555; } + td { padding: 8px 0; border-bottom: 1px solid #eee; } + tr:hover td { background: #fafafa; } + ul, ol { margin: 12px 0 12px 20px; font-size: 15px; } + li { margin: 6px 0; } + code { background: #f7f7f7; padding: 2px 6px; border-radius: 2px; font-size: 13px; + font-family: 'Courier New', monospace; } + pre { background: #f7f7f7; color: #333; padding: 18px; margin: 16px 0; + overflow-x: auto; font-size: 13px; border: 1px solid #e5e5e5; } + pre code { background: transparent; padding: 0; } + blockquote { border-left: 3px solid #000; padding: 8px 20px; margin: 16px 0; + color: #555; font-style: italic; } + hr { border: none; border-top: 1px solid #ddd; margin: 32px 0; } + """; + #endregion + + #region Elegant — 우아한 + private const string CssElegant = """ + @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif; + background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; } + .container { max-width: 860px; margin: 0 auto; background: #fff; + border-radius: 4px; padding: 56px 52px; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + border: 1px solid #e8e4dd; } + h1 { font-family: 'Playfair Display', Georgia, serif; font-size: 30px; + font-weight: 700; color: #2c2416; margin-bottom: 6px; letter-spacing: -0.3px; } + h2 { font-family: 'Playfair Display', Georgia, serif; font-size: 20px; + font-weight: 600; margin: 36px 0 14px; color: #2c2416; + border-bottom: 1px solid #d4c9b8; padding-bottom: 8px; } + h3 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; color: #8b7a5e; } + .meta { font-size: 12px; color: #b0a48e; margin-bottom: 28px; letter-spacing: 0.5px; + text-transform: uppercase; } + p { margin: 10px 0; font-size: 14.5px; } + table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; } + th { background: #f8f5f0; text-align: left; padding: 10px 14px; font-weight: 600; + color: #5a4d38; border-bottom: 2px solid #d4c9b8; font-size: 12.5px; + letter-spacing: 0.5px; } + td { padding: 9px 14px; border-bottom: 1px solid #f0ece5; } + tr:hover td { background: #fdfcfa; } + ul, ol { margin: 10px 0 10px 26px; font-size: 14.5px; } + li { margin: 5px 0; } + code { background: #f8f5f0; padding: 2px 7px; border-radius: 3px; font-size: 12.5px; + font-family: 'Courier New', monospace; color: #8b6914; } + pre { background: #2c2416; color: #e8e0d0; padding: 18px; border-radius: 4px; + overflow-x: auto; font-size: 12.5px; margin: 16px 0; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 3px solid #c9a96e; padding: 12px 20px; margin: 16px 0; + background: #fdf9f0; color: #5a4d38; font-style: italic; + font-family: 'Playfair Display', Georgia, serif; } + .ornament { text-align: center; color: #c9a96e; font-size: 18px; margin: 24px 0; letter-spacing: 8px; } + """; + #endregion + + #region Dark — 다크 모드 + private const string CssDark = """ + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; + background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; } + .container { max-width: 880px; margin: 0 auto; background: #161b22; + border-radius: 12px; padding: 52px; + border: 1px solid #30363d; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); } + h1 { font-size: 28px; font-weight: 700; color: #f0f6fc; margin-bottom: 4px; } + h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #f0f6fc; + border-bottom: 1px solid #30363d; padding-bottom: 8px; } + h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #58a6ff; } + .meta { font-size: 12px; color: #8b949e; margin-bottom: 28px; } + p { margin: 10px 0; font-size: 14.5px; color: #c9d1d9; } + table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; + border: 1px solid #30363d; border-radius: 8px; overflow: hidden; } + th { background: #21262d; text-align: left; padding: 10px 14px; font-weight: 600; + color: #f0f6fc; border-bottom: 1px solid #30363d; } + td { padding: 9px 14px; border-bottom: 1px solid #21262d; color: #c9d1d9; } + tr:hover td { background: #1c2128; } + ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; color: #c9d1d9; } + li { margin: 5px 0; } + code { background: #1c2128; padding: 2px 8px; border-radius: 6px; font-size: 13px; + font-family: 'JetBrains Mono', Consolas, monospace; color: #79c0ff; } + pre { background: #0d1117; color: #c9d1d9; padding: 20px; border-radius: 8px; + overflow-x: auto; font-size: 13px; margin: 16px 0; + border: 1px solid #30363d; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 3px solid #58a6ff; padding: 12px 20px; margin: 16px 0; + background: #161b22; color: #8b949e; + border-radius: 0 8px 8px 0; } + a { color: #58a6ff; text-decoration: none; } + a:hover { text-decoration: underline; } + .label { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; + font-weight: 500; border: 1px solid #30363d; color: #8b949e; margin: 2px 4px 2px 0; } + """; + #endregion + + #region Colorful — 컬러풀 + private const string CssColorful = """ + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif; + background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%); + min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; } + .container { max-width: 880px; margin: 0 auto; background: #fff; + border-radius: 20px; padding: 52px; + box-shadow: 0 12px 40px rgba(0,0,0,0.08); } + h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; } + h2 { font-size: 20px; font-weight: 700; margin: 34px 0 14px; color: #6c5ce7; + padding: 6px 14px; background: #f8f0ff; border-radius: 8px; display: inline-block; } + h3 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; color: #00b894; } + .meta { font-size: 12px; color: #b2bec3; margin-bottom: 28px; } + p { margin: 10px 0; font-size: 14.5px; } + table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0; + font-size: 13.5px; border-radius: 14px; overflow: hidden; + box-shadow: 0 2px 8px rgba(108,92,231,0.1); } + th { background: linear-gradient(135deg, #a29bfe, #6c5ce7); color: #fff; + text-align: left; padding: 12px 14px; font-weight: 700; } + td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; } + tr:hover td { background: #faf0ff; } + ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } + li { margin: 5px 0; } + li::marker { color: #e17055; font-weight: 700; } + code { background: #fff3e0; padding: 2px 8px; border-radius: 6px; font-size: 13px; + font-family: Consolas, monospace; color: #e17055; } + pre { background: #2d3436; color: #dfe6e9; padding: 20px; border-radius: 14px; + overflow-x: auto; font-size: 13px; margin: 16px 0; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 4px solid #fdcb6e; padding: 14px 20px; margin: 16px 0; + background: #fffbf0; border-radius: 0 12px 12px 0; color: #636e72; } + .chip { display: inline-block; padding: 4px 14px; border-radius: 20px; font-size: 12px; + font-weight: 700; color: #fff; margin: 3px 4px 3px 0; } + .chip-red { background: #e17055; } .chip-blue { background: #74b9ff; } + .chip-green { background: #00b894; } .chip-purple { background: #6c5ce7; } + .chip-yellow { background: #fdcb6e; color: #2d3436; } + """; + #endregion + + #region Corporate — 기업 공식 + private const string CssCorporate = """ + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; + background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; } + .container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); } + .header-bar { background: #003366; color: #fff; padding: 28px 40px 20px; + border-bottom: 3px solid #ff6600; } + .header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; } + .header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; } + .body-content { padding: 36px 40px 40px; } + h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; } + h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366; + border-left: 4px solid #ff6600; padding-left: 12px; } + h3 { font-size: 14.5px; font-weight: 600; margin: 20px 0 8px; color: #004488; } + .meta { font-size: 11.5px; color: #999; margin-bottom: 20px; } + p { margin: 8px 0; font-size: 13.5px; } + table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px; + border: 1px solid #ddd; } + th { background: #003366; color: #fff; text-align: left; padding: 8px 12px; + font-weight: 600; font-size: 11.5px; } + td { padding: 7px 12px; border: 1px solid #e0e0e0; } + tr:nth-child(even) td { background: #f9f9f9; } + ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; } + li { margin: 3px 0; } + code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 12px; + font-family: Consolas, monospace; } + pre { background: #f4f4f4; color: #333; padding: 14px; border-radius: 4px; + overflow-x: auto; font-size: 12px; margin: 12px 0; border: 1px solid #ddd; } + pre code { background: transparent; padding: 0; } + blockquote { border-left: 4px solid #ff6600; padding: 10px 16px; margin: 12px 0; + background: #fff8f0; color: #555; } + .footer { text-align: center; font-size: 10.5px; color: #aaa; margin-top: 32px; + padding-top: 12px; border-top: 1px solid #eee; } + .stamp { display: inline-block; border: 2px solid #003366; color: #003366; padding: 4px 16px; + border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: 1px; } + """; + #endregion + + #region Magazine — 매거진 + private const string CssMagazine = """ + @import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif; + background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; } + .container { max-width: 900px; margin: 0 auto; background: #fff; + border-radius: 2px; padding: 0; overflow: hidden; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); } + .hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + padding: 48px 44px 36px; color: #fff; } + .hero h1 { font-family: 'Merriweather', Georgia, serif; font-size: 32px; font-weight: 900; + line-height: 1.3; margin-bottom: 8px; } + .hero .meta { color: rgba(255,255,255,0.6); margin-bottom: 0; font-size: 13px; } + .content { padding: 40px 44px 44px; } + h1 { font-family: 'Merriweather', Georgia, serif; font-size: 28px; font-weight: 900; + color: #1a1a2e; margin-bottom: 4px; } + h2 { font-family: 'Merriweather', Georgia, serif; font-size: 20px; font-weight: 700; + margin: 36px 0 14px; color: #1a1a2e; } + h3 { font-size: 15px; font-weight: 700; margin: 24px 0 10px; color: #e94560; + text-transform: uppercase; letter-spacing: 1px; font-size: 12px; } + .meta { font-size: 12px; color: #999; margin-bottom: 24px; } + p { margin: 10px 0; font-size: 15px; } + p:first-of-type::first-letter { font-family: 'Merriweather', Georgia, serif; + font-size: 48px; float: left; line-height: 1; padding-right: 8px; color: #e94560; + font-weight: 900; } + table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; } + th { background: #1a1a2e; color: #fff; text-align: left; padding: 10px 14px; + font-weight: 600; } + td { padding: 9px 14px; border-bottom: 1px solid #eee; } + tr:hover td { background: #fafafa; } + ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } + li { margin: 5px 0; } + code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 12.5px; + font-family: 'Courier New', monospace; } + pre { background: #1a1a2e; color: #e0e0e0; padding: 18px; border-radius: 4px; + overflow-x: auto; font-size: 12.5px; margin: 16px 0; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { font-family: 'Merriweather', Georgia, serif; font-size: 18px; + font-style: italic; color: #555; border: none; padding: 20px 0; margin: 24px 0; + text-align: center; position: relative; } + blockquote::before { content: '\201C'; font-size: 60px; color: #e94560; + position: absolute; top: -10px; left: 50%; transform: translateX(-50%); + opacity: 0.3; } + .pullquote { font-size: 20px; font-family: 'Merriweather', Georgia, serif; + font-weight: 700; color: #e94560; border-top: 3px solid #e94560; + border-bottom: 3px solid #e94560; padding: 16px 0; margin: 24px 0; + text-align: center; } + """; + #endregion + + #region Dashboard — 대시보드 + private const string CssDashboard = """ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; + background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; } + .container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; } + h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; } + h2 { font-size: 17px; font-weight: 600; margin: 28px 0 14px; color: #1a1a2e; } + h3 { font-size: 14px; font-weight: 600; margin: 18px 0 8px; color: #6c7893; } + .meta { font-size: 12px; color: #8c95a6; margin-bottom: 24px; } + p { margin: 8px 0; font-size: 13.5px; color: #4a5568; } + .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; margin: 20px 0; } + .kpi-card { background: #fff; border-radius: 12px; padding: 20px; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); } + .kpi-card .kpi-label { font-size: 12px; color: #8c95a6; font-weight: 500; + text-transform: uppercase; letter-spacing: 0.5px; } + .kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: #1a1a2e; margin: 4px 0; } + .kpi-card .kpi-change { font-size: 12px; font-weight: 600; } + .kpi-up { color: #10b981; } .kpi-down { color: #ef4444; } + .chart-area { background: #fff; border-radius: 12px; padding: 24px; margin: 16px 0; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); min-height: 200px; } + table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; + background: #fff; border-radius: 10px; overflow: hidden; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); } + th { background: #f7f8fa; text-align: left; padding: 10px 14px; font-weight: 600; + color: #6c7893; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.5px; + border-bottom: 1px solid #edf0f4; } + td { padding: 10px 14px; border-bottom: 1px solid #f3f4f6; } + tr:hover td { background: #f9fafb; } + ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; } + li { margin: 4px 0; } + code { background: #f1f3f5; padding: 2px 7px; border-radius: 5px; font-size: 12px; + font-family: 'JetBrains Mono', Consolas, monospace; } + pre { background: #1a1a2e; color: #c9d1d9; padding: 18px; border-radius: 10px; + overflow-x: auto; font-size: 12px; margin: 14px 0; } + pre code { background: transparent; color: inherit; padding: 0; } + blockquote { border-left: 3px solid #4b5efc; padding: 10px 16px; margin: 14px 0; + background: #f0f0ff; border-radius: 0 8px 8px 0; font-size: 13px; } + .status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; + font-size: 11px; font-weight: 600; } + .status-ok { background: #d1fae5; color: #065f46; } + .status-warn { background: #fef3c7; color: #92400e; } + .status-err { background: #fee2e2; color: #991b1b; } + """; + #endregion + + // ════════════════════════════════════════════════════════════════════ + // 공통 CSS 컴포넌트 (모든 무드에 자동 첨부) + // ════════════════════════════════════════════════════════════════════ + + #region Shared — 공통 컴포넌트 + private const string CssShared = """ + + /* ── 목차 (TOC) ── */ + nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px; + padding: 20px 28px; margin: 24px 0 32px; } + nav.toc h2 { font-size: 15px; font-weight: 700; margin: 0 0 12px; padding: 0; border: none; + color: inherit; display: block; background: none; } + nav.toc ul { list-style: none; margin: 0; padding: 0; } + nav.toc li { margin: 4px 0; } + nav.toc li.toc-h3 { padding-left: 18px; } + nav.toc a { text-decoration: none; color: #4b5efc; font-size: 13.5px; } + nav.toc a:hover { text-decoration: underline; } + + /* ── 커버 페이지 ── */ + .cover-page { text-align: center; padding: 80px 40px 60px; margin: -56px -52px 40px; + border-radius: 16px 16px 0 0; position: relative; overflow: hidden; + background: linear-gradient(135deg, #4b5efc 0%, #7c3aed 100%); color: #fff; } + .cover-page h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; color: #fff; + -webkit-text-fill-color: #fff; } + .cover-page .cover-subtitle { font-size: 18px; opacity: 0.9; margin-bottom: 24px; } + .cover-page .cover-meta { font-size: 13px; opacity: 0.7; } + .cover-page .cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5); + margin: 20px auto; border-radius: 2px; } + + /* ── 콜아웃 (callout) ── */ + .callout { border-radius: 8px; padding: 16px 20px; margin: 16px 0; font-size: 14px; + border-left: 4px solid; display: flex; gap: 10px; align-items: flex-start; } + .callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; } + .callout-info { background: #eff6ff; border-color: #3b82f6; color: #1e40af; } + .callout-info::before { content: 'ℹ️'; } + .callout-warning { background: #fffbeb; border-color: #f59e0b; color: #92400e; } + .callout-warning::before { content: '⚠️'; } + .callout-tip { background: #f0fdf4; border-color: #22c55e; color: #166534; } + .callout-tip::before { content: '💡'; } + .callout-danger { background: #fef2f2; border-color: #ef4444; color: #991b1b; } + .callout-danger::before { content: '🚨'; } + .callout-note { background: #f5f3ff; border-color: #8b5cf6; color: #5b21b6; } + .callout-note::before { content: '📝'; } + + /* ── 배지 (badge) — 공통 ── */ + .badge, .tag, .chip { display: inline-block; padding: 3px 10px; border-radius: 20px; + font-size: 11px; font-weight: 600; margin: 2px 4px 2px 0; } + .badge-blue { background: #dbeafe; color: #1e40af; } + .badge-green { background: #d1fae5; color: #065f46; } + .badge-red { background: #fee2e2; color: #991b1b; } + .badge-yellow { background: #fef3c7; color: #92400e; } + .badge-purple { background: #ede9fe; color: #5b21b6; } + .badge-gray { background: #f3f4f6; color: #374151; } + .badge-orange { background: #ffedd5; color: #9a3412; } + + /* ── 하이라이트 박스 ── */ + .highlight-box { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%); + padding: 16px 20px; border-radius: 10px; margin: 16px 0; } + + /* ── CSS 차트 (bar/horizontal) ── */ + .chart-bar { margin: 20px 0; } + .chart-bar .bar-item { display: flex; align-items: center; margin: 6px 0; gap: 10px; } + .chart-bar .bar-label { min-width: 100px; font-size: 13px; text-align: right; flex-shrink: 0; } + .chart-bar .bar-track { flex: 1; background: #e5e7eb; border-radius: 6px; height: 22px; + overflow: hidden; } + .chart-bar .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; + padding: 0 8px; font-size: 11px; font-weight: 600; color: #fff; + transition: width 0.3s ease; min-width: fit-content; } + .bar-fill.blue { background: #3b82f6; } .bar-fill.green { background: #22c55e; } + .bar-fill.red { background: #ef4444; } .bar-fill.yellow { background: #f59e0b; } + .bar-fill.purple { background: #8b5cf6; } .bar-fill.orange { background: #f97316; } + + /* ── CSS 도넛 차트 ── */ + .chart-donut { width: 160px; height: 160px; border-radius: 50%; margin: 20px auto; + background: conic-gradient(var(--seg1-color, #3b82f6) 0% var(--seg1, 0%), + var(--seg2-color, #22c55e) var(--seg1, 0%) var(--seg2, 0%), + var(--seg3-color, #f59e0b) var(--seg2, 0%) var(--seg3, 0%), + var(--seg4-color, #ef4444) var(--seg3, 0%) var(--seg4, 0%), + #e5e7eb var(--seg4, 0%) 100%); + display: flex; align-items: center; justify-content: center; position: relative; } + .chart-donut::after { content: ''; width: 100px; height: 100px; background: #fff; + border-radius: 50%; position: absolute; } + .chart-donut .donut-label { position: absolute; z-index: 1; font-size: 18px; font-weight: 700; } + + /* ── 진행률 바 ── */ + .progress { background: #e5e7eb; border-radius: 8px; height: 10px; margin: 8px 0; + overflow: hidden; } + .progress-fill { height: 100%; border-radius: 8px; background: #3b82f6; } + + /* ── 타임라인 ── */ + .timeline { position: relative; padding-left: 28px; margin: 20px 0; } + .timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; + width: 2px; background: #e5e7eb; } + .timeline-item { position: relative; margin: 16px 0; } + .timeline-item::before { content: ''; position: absolute; left: -24px; top: 5px; + width: 12px; height: 12px; border-radius: 50%; background: #4b5efc; + border: 2px solid #fff; box-shadow: 0 0 0 2px #4b5efc; } + .timeline-item .timeline-date { font-size: 12px; color: #6b7280; font-weight: 600; } + .timeline-item .timeline-content { font-size: 14px; margin-top: 4px; } + + /* ── 섹션 자동 번호 ── */ + body { counter-reset: section; } + h2.numbered { counter-increment: section; counter-reset: subsection; } + h2.numbered::before { content: counter(section) '. '; } + h3.numbered { counter-increment: subsection; } + h3.numbered::before { content: counter(section) '-' counter(subsection) '. '; } + + /* ── 그리드 레이아웃 ── */ + .grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; } + .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 16px 0; } + .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; } + + /* ── 카드 공통 ── */ + .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; + padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); } + .card-header { font-size: 15px; font-weight: 700; margin-bottom: 8px; } + + /* ── 구분선 ── */ + .divider { border: none; border-top: 1px solid #e5e7eb; margin: 32px 0; } + .divider-thick { border: none; border-top: 3px solid #e5e7eb; margin: 40px 0; } + + /* ── 인쇄/PDF 최적화 ── */ + @media print { + body { background: #fff !important; padding: 0 !important; } + .container { box-shadow: none !important; border: none !important; + max-width: none !important; padding: 20px !important; } + .cover-page { break-after: page; } + h2, h3 { break-after: avoid; } + table, figure, .chart-bar, .callout { break-inside: avoid; } + nav.toc { break-after: page; } + a { color: inherit !important; text-decoration: none !important; } + a[href]::after { content: ' (' attr(href) ')'; font-size: 10px; color: #999; } + .no-print { display: none !important; } + } + """; + #endregion +} diff --git a/src/AxCopilot/Services/Agent/TemplateService.cs b/src/AxCopilot/Services/Agent/TemplateService.cs index 1a4f61b..46755f0 100644 --- a/src/AxCopilot/Services/Agent/TemplateService.cs +++ b/src/AxCopilot/Services/Agent/TemplateService.cs @@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent; /// 테마 무드(현대적, 전문가, 창의적 등)에 따라 CSS 스타일을 제공합니다. /// HtmlSkill, DocxSkill 등에서 호출하여 문서 생성 시 적용합니다. /// -public static class TemplateService +public static partial class TemplateService { /// 사용 가능한 테마 무드 목록. public static readonly TemplateMood[] AvailableMoods = @@ -173,561 +173,6 @@ public static class TemplateService /// 무드 갤러리용 색상 정보. public record MoodColors(string Background, string CardBg, string PrimaryText, string SecondaryText, string Accent, string Border); - - // ════════════════════════════════════════════════════════════════════ - // CSS 템플릿 정의 - // ════════════════════════════════════════════════════════════════════ - - #region Modern — 현대적 - private const string CssModern = """ - @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; - background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; } - .container { max-width: 880px; margin: 0 auto; background: #fff; - border-radius: 16px; padding: 56px 52px; - box-shadow: 0 4px 24px rgba(0,0,0,0.06); } - h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; } - h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #1d1d1f; - padding-bottom: 8px; border-bottom: 2px solid #e5e5ea; } - h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #0071e3; } - .meta { font-size: 12px; color: #86868b; margin-bottom: 28px; letter-spacing: 0.3px; } - p { margin: 10px 0; font-size: 14.5px; } - table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px; - border-radius: 10px; overflow: hidden; } - th { background: #f5f5f7; text-align: left; padding: 12px 14px; font-weight: 600; - color: #1d1d1f; border-bottom: 2px solid #d2d2d7; } - td { padding: 10px 14px; border-bottom: 1px solid #f0f0f2; } - tr:hover td { background: #f9f9fb; } - ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } - li { margin: 5px 0; } - code { background: #f5f5f7; padding: 2px 8px; border-radius: 6px; font-size: 13px; - font-family: 'SF Mono', Consolas, monospace; color: #e3116c; } - pre { background: #1d1d1f; color: #f5f5f7; padding: 20px; border-radius: 12px; - overflow-x: auto; font-size: 13px; margin: 16px 0; line-height: 1.6; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 3px solid #0071e3; padding: 12px 20px; margin: 16px 0; - background: #f0f7ff; color: #1d1d1f; border-radius: 0 8px 8px 0; font-size: 14px; } - .highlight { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%); - padding: 16px 20px; border-radius: 10px; margin: 16px 0; } - .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; - font-weight: 600; background: #0071e3; color: #fff; margin: 2px 4px 2px 0; } - """; - #endregion - - #region Professional — 전문가 - private const string CssProfessional = """ - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; - background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; } - .container { max-width: 900px; margin: 0 auto; background: #fff; - border-radius: 8px; padding: 48px; - box-shadow: 0 1px 8px rgba(0,0,0,0.08); - border-top: 4px solid #1e3a5f; } - h1 { font-size: 26px; font-weight: 700; color: #1e3a5f; margin-bottom: 4px; } - h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; color: #1e3a5f; - border-bottom: 2px solid #c8d6e5; padding-bottom: 6px; } - h3 { font-size: 15px; font-weight: 600; margin: 22px 0 8px; color: #2c5282; } - .meta { font-size: 12px; color: #94a3b8; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0; - padding-bottom: 12px; } - p { margin: 8px 0; font-size: 14px; } - table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px; - border: 1px solid #e2e8f0; } - th { background: #1e3a5f; color: #fff; text-align: left; padding: 10px 14px; - font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.5px; } - td { padding: 9px 14px; border-bottom: 1px solid #e2e8f0; } - tr:nth-child(even) td { background: #f8fafc; } - tr:hover td { background: #eef2ff; } - ul, ol { margin: 8px 0 8px 24px; } - li { margin: 4px 0; font-size: 14px; } - code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12.5px; - font-family: Consolas, monospace; color: #1e3a5f; } - pre { background: #0f172a; color: #e2e8f0; padding: 18px; border-radius: 6px; - overflow-x: auto; font-size: 12.5px; margin: 14px 0; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 4px solid #1e3a5f; padding: 10px 18px; margin: 14px 0; - background: #f0f4f8; color: #334155; } - .callout { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px; - padding: 14px 18px; margin: 14px 0; font-size: 13.5px; } - .callout strong { color: #1e40af; } - """; - #endregion - - #region Creative — 아이디어 - private const string CssCreative = """ - @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; } - .container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95); - backdrop-filter: blur(20px); border-radius: 20px; padding: 52px; - box-shadow: 0 20px 60px rgba(0,0,0,0.15); } - h1 { font-size: 30px; font-weight: 700; - background: linear-gradient(135deg, #667eea, #e040fb); - -webkit-background-clip: text; -webkit-text-fill-color: transparent; - margin-bottom: 4px; } - h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #553c9a; - position: relative; padding-left: 16px; } - h2::before { content: ''; position: absolute; left: 0; top: 4px; width: 4px; height: 22px; - background: linear-gradient(180deg, #667eea, #e040fb); border-radius: 4px; } - h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: #7c3aed; } - .meta { font-size: 12px; color: #a0aec0; margin-bottom: 28px; } - p { margin: 10px 0; font-size: 14.5px; } - table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0; - font-size: 13.5px; border-radius: 12px; overflow: hidden; - box-shadow: 0 4px 12px rgba(102,126,234,0.1); } - th { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; - text-align: left; padding: 12px 14px; font-weight: 600; } - td { padding: 10px 14px; border-bottom: 1px solid #f0e7fe; } - tr:hover td { background: #faf5ff; } - ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } - li { margin: 5px 0; } - li::marker { color: #7c3aed; } - code { background: #f5f3ff; padding: 2px 8px; border-radius: 6px; font-size: 13px; - font-family: 'Fira Code', Consolas, monospace; color: #7c3aed; } - pre { background: #1a1a2e; color: #e0d4f5; padding: 20px; border-radius: 14px; - overflow-x: auto; font-size: 13px; margin: 16px 0; - border: 1px solid rgba(124,58,237,0.2); } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 4px solid #7c3aed; padding: 14px 20px; margin: 16px 0; - background: linear-gradient(135deg, #f5f3ff, #faf5ff); - border-radius: 0 12px 12px 0; font-style: italic; } - .card { background: #fff; border: 1px solid #e9d8fd; border-radius: 14px; - padding: 20px; margin: 14px 0; box-shadow: 0 2px 8px rgba(124,58,237,0.08); } - .tag { display: inline-block; padding: 3px 12px; border-radius: 20px; font-size: 11px; - font-weight: 500; background: linear-gradient(135deg, #667eea, #764ba2); - color: #fff; margin: 2px 4px 2px 0; } - """; - #endregion - - #region Minimal — 미니멀 - private const string CssMinimal = """ - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Georgia', 'Batang', serif; - background: #fff; color: #222; line-height: 1.85; padding: 60px 24px; } - .container { max-width: 720px; margin: 0 auto; padding: 0; } - h1 { font-size: 32px; font-weight: 400; color: #000; margin-bottom: 4px; - letter-spacing: -0.5px; } - h2 { font-size: 20px; font-weight: 400; margin: 40px 0 14px; color: #000; - border-bottom: 1px solid #ddd; padding-bottom: 8px; } - h3 { font-size: 16px; font-weight: 600; margin: 28px 0 10px; color: #333; } - .meta { font-size: 12px; color: #999; margin-bottom: 36px; font-style: italic; } - p { margin: 12px 0; font-size: 15px; text-align: justify; } - table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 14px; } - th { text-align: left; padding: 8px 0; font-weight: 600; border-bottom: 2px solid #000; - font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #555; } - td { padding: 8px 0; border-bottom: 1px solid #eee; } - tr:hover td { background: #fafafa; } - ul, ol { margin: 12px 0 12px 20px; font-size: 15px; } - li { margin: 6px 0; } - code { background: #f7f7f7; padding: 2px 6px; border-radius: 2px; font-size: 13px; - font-family: 'Courier New', monospace; } - pre { background: #f7f7f7; color: #333; padding: 18px; margin: 16px 0; - overflow-x: auto; font-size: 13px; border: 1px solid #e5e5e5; } - pre code { background: transparent; padding: 0; } - blockquote { border-left: 3px solid #000; padding: 8px 20px; margin: 16px 0; - color: #555; font-style: italic; } - hr { border: none; border-top: 1px solid #ddd; margin: 32px 0; } - """; - #endregion - - #region Elegant — 우아한 - private const string CssElegant = """ - @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif; - background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; } - .container { max-width: 860px; margin: 0 auto; background: #fff; - border-radius: 4px; padding: 56px 52px; - box-shadow: 0 1px 4px rgba(0,0,0,0.06); - border: 1px solid #e8e4dd; } - h1 { font-family: 'Playfair Display', Georgia, serif; font-size: 30px; - font-weight: 700; color: #2c2416; margin-bottom: 6px; letter-spacing: -0.3px; } - h2 { font-family: 'Playfair Display', Georgia, serif; font-size: 20px; - font-weight: 600; margin: 36px 0 14px; color: #2c2416; - border-bottom: 1px solid #d4c9b8; padding-bottom: 8px; } - h3 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; color: #8b7a5e; } - .meta { font-size: 12px; color: #b0a48e; margin-bottom: 28px; letter-spacing: 0.5px; - text-transform: uppercase; } - p { margin: 10px 0; font-size: 14.5px; } - table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; } - th { background: #f8f5f0; text-align: left; padding: 10px 14px; font-weight: 600; - color: #5a4d38; border-bottom: 2px solid #d4c9b8; font-size: 12.5px; - letter-spacing: 0.5px; } - td { padding: 9px 14px; border-bottom: 1px solid #f0ece5; } - tr:hover td { background: #fdfcfa; } - ul, ol { margin: 10px 0 10px 26px; font-size: 14.5px; } - li { margin: 5px 0; } - code { background: #f8f5f0; padding: 2px 7px; border-radius: 3px; font-size: 12.5px; - font-family: 'Courier New', monospace; color: #8b6914; } - pre { background: #2c2416; color: #e8e0d0; padding: 18px; border-radius: 4px; - overflow-x: auto; font-size: 12.5px; margin: 16px 0; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 3px solid #c9a96e; padding: 12px 20px; margin: 16px 0; - background: #fdf9f0; color: #5a4d38; font-style: italic; - font-family: 'Playfair Display', Georgia, serif; } - .ornament { text-align: center; color: #c9a96e; font-size: 18px; margin: 24px 0; letter-spacing: 8px; } - """; - #endregion - - #region Dark — 다크 모드 - private const string CssDark = """ - @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; - background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; } - .container { max-width: 880px; margin: 0 auto; background: #161b22; - border-radius: 12px; padding: 52px; - border: 1px solid #30363d; - box-shadow: 0 8px 32px rgba(0,0,0,0.3); } - h1 { font-size: 28px; font-weight: 700; color: #f0f6fc; margin-bottom: 4px; } - h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #f0f6fc; - border-bottom: 1px solid #30363d; padding-bottom: 8px; } - h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #58a6ff; } - .meta { font-size: 12px; color: #8b949e; margin-bottom: 28px; } - p { margin: 10px 0; font-size: 14.5px; color: #c9d1d9; } - table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; - border: 1px solid #30363d; border-radius: 8px; overflow: hidden; } - th { background: #21262d; text-align: left; padding: 10px 14px; font-weight: 600; - color: #f0f6fc; border-bottom: 1px solid #30363d; } - td { padding: 9px 14px; border-bottom: 1px solid #21262d; color: #c9d1d9; } - tr:hover td { background: #1c2128; } - ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; color: #c9d1d9; } - li { margin: 5px 0; } - code { background: #1c2128; padding: 2px 8px; border-radius: 6px; font-size: 13px; - font-family: 'JetBrains Mono', Consolas, monospace; color: #79c0ff; } - pre { background: #0d1117; color: #c9d1d9; padding: 20px; border-radius: 8px; - overflow-x: auto; font-size: 13px; margin: 16px 0; - border: 1px solid #30363d; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 3px solid #58a6ff; padding: 12px 20px; margin: 16px 0; - background: #161b22; color: #8b949e; - border-radius: 0 8px 8px 0; } - a { color: #58a6ff; text-decoration: none; } - a:hover { text-decoration: underline; } - .label { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; - font-weight: 500; border: 1px solid #30363d; color: #8b949e; margin: 2px 4px 2px 0; } - """; - #endregion - - #region Colorful — 컬러풀 - private const string CssColorful = """ - @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif; - background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%); - min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; } - .container { max-width: 880px; margin: 0 auto; background: #fff; - border-radius: 20px; padding: 52px; - box-shadow: 0 12px 40px rgba(0,0,0,0.08); } - h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; } - h2 { font-size: 20px; font-weight: 700; margin: 34px 0 14px; color: #6c5ce7; - padding: 6px 14px; background: #f8f0ff; border-radius: 8px; display: inline-block; } - h3 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; color: #00b894; } - .meta { font-size: 12px; color: #b2bec3; margin-bottom: 28px; } - p { margin: 10px 0; font-size: 14.5px; } - table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0; - font-size: 13.5px; border-radius: 14px; overflow: hidden; - box-shadow: 0 2px 8px rgba(108,92,231,0.1); } - th { background: linear-gradient(135deg, #a29bfe, #6c5ce7); color: #fff; - text-align: left; padding: 12px 14px; font-weight: 700; } - td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; } - tr:hover td { background: #faf0ff; } - ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } - li { margin: 5px 0; } - li::marker { color: #e17055; font-weight: 700; } - code { background: #fff3e0; padding: 2px 8px; border-radius: 6px; font-size: 13px; - font-family: Consolas, monospace; color: #e17055; } - pre { background: #2d3436; color: #dfe6e9; padding: 20px; border-radius: 14px; - overflow-x: auto; font-size: 13px; margin: 16px 0; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 4px solid #fdcb6e; padding: 14px 20px; margin: 16px 0; - background: #fffbf0; border-radius: 0 12px 12px 0; color: #636e72; } - .chip { display: inline-block; padding: 4px 14px; border-radius: 20px; font-size: 12px; - font-weight: 700; color: #fff; margin: 3px 4px 3px 0; } - .chip-red { background: #e17055; } .chip-blue { background: #74b9ff; } - .chip-green { background: #00b894; } .chip-purple { background: #6c5ce7; } - .chip-yellow { background: #fdcb6e; color: #2d3436; } - """; - #endregion - - #region Corporate — 기업 공식 - private const string CssCorporate = """ - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; - background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; } - .container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0; - box-shadow: 0 1px 4px rgba(0,0,0,0.1); } - .header-bar { background: #003366; color: #fff; padding: 28px 40px 20px; - border-bottom: 3px solid #ff6600; } - .header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; } - .header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; } - .body-content { padding: 36px 40px 40px; } - h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; } - h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366; - border-left: 4px solid #ff6600; padding-left: 12px; } - h3 { font-size: 14.5px; font-weight: 600; margin: 20px 0 8px; color: #004488; } - .meta { font-size: 11.5px; color: #999; margin-bottom: 20px; } - p { margin: 8px 0; font-size: 13.5px; } - table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px; - border: 1px solid #ddd; } - th { background: #003366; color: #fff; text-align: left; padding: 8px 12px; - font-weight: 600; font-size: 11.5px; } - td { padding: 7px 12px; border: 1px solid #e0e0e0; } - tr:nth-child(even) td { background: #f9f9f9; } - ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; } - li { margin: 3px 0; } - code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 12px; - font-family: Consolas, monospace; } - pre { background: #f4f4f4; color: #333; padding: 14px; border-radius: 4px; - overflow-x: auto; font-size: 12px; margin: 12px 0; border: 1px solid #ddd; } - pre code { background: transparent; padding: 0; } - blockquote { border-left: 4px solid #ff6600; padding: 10px 16px; margin: 12px 0; - background: #fff8f0; color: #555; } - .footer { text-align: center; font-size: 10.5px; color: #aaa; margin-top: 32px; - padding-top: 12px; border-top: 1px solid #eee; } - .stamp { display: inline-block; border: 2px solid #003366; color: #003366; padding: 4px 16px; - border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 1px; } - """; - #endregion - - #region Magazine — 매거진 - private const string CssMagazine = """ - @import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif; - background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; } - .container { max-width: 900px; margin: 0 auto; background: #fff; - border-radius: 2px; padding: 0; overflow: hidden; - box-shadow: 0 4px 16px rgba(0,0,0,0.08); } - .hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); - padding: 48px 44px 36px; color: #fff; } - .hero h1 { font-family: 'Merriweather', Georgia, serif; font-size: 32px; font-weight: 900; - line-height: 1.3; margin-bottom: 8px; } - .hero .meta { color: rgba(255,255,255,0.6); margin-bottom: 0; font-size: 13px; } - .content { padding: 40px 44px 44px; } - h1 { font-family: 'Merriweather', Georgia, serif; font-size: 28px; font-weight: 900; - color: #1a1a2e; margin-bottom: 4px; } - h2 { font-family: 'Merriweather', Georgia, serif; font-size: 20px; font-weight: 700; - margin: 36px 0 14px; color: #1a1a2e; } - h3 { font-size: 15px; font-weight: 700; margin: 24px 0 10px; color: #e94560; - text-transform: uppercase; letter-spacing: 1px; font-size: 12px; } - .meta { font-size: 12px; color: #999; margin-bottom: 24px; } - p { margin: 10px 0; font-size: 15px; } - p:first-of-type::first-letter { font-family: 'Merriweather', Georgia, serif; - font-size: 48px; float: left; line-height: 1; padding-right: 8px; color: #e94560; - font-weight: 900; } - table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; } - th { background: #1a1a2e; color: #fff; text-align: left; padding: 10px 14px; - font-weight: 600; } - td { padding: 9px 14px; border-bottom: 1px solid #eee; } - tr:hover td { background: #fafafa; } - ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; } - li { margin: 5px 0; } - code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 12.5px; - font-family: 'Courier New', monospace; } - pre { background: #1a1a2e; color: #e0e0e0; padding: 18px; border-radius: 4px; - overflow-x: auto; font-size: 12.5px; margin: 16px 0; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { font-family: 'Merriweather', Georgia, serif; font-size: 18px; - font-style: italic; color: #555; border: none; padding: 20px 0; margin: 24px 0; - text-align: center; position: relative; } - blockquote::before { content: '\201C'; font-size: 60px; color: #e94560; - position: absolute; top: -10px; left: 50%; transform: translateX(-50%); - opacity: 0.3; } - .pullquote { font-size: 20px; font-family: 'Merriweather', Georgia, serif; - font-weight: 700; color: #e94560; border-top: 3px solid #e94560; - border-bottom: 3px solid #e94560; padding: 16px 0; margin: 24px 0; - text-align: center; } - """; - #endregion - - #region Dashboard — 대시보드 - private const string CssDashboard = """ - @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); - * { margin:0; padding:0; box-sizing:border-box; } - body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; - background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; } - .container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; } - h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; } - h2 { font-size: 17px; font-weight: 600; margin: 28px 0 14px; color: #1a1a2e; } - h3 { font-size: 14px; font-weight: 600; margin: 18px 0 8px; color: #6c7893; } - .meta { font-size: 12px; color: #8c95a6; margin-bottom: 24px; } - p { margin: 8px 0; font-size: 13.5px; color: #4a5568; } - .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; margin: 20px 0; } - .kpi-card { background: #fff; border-radius: 12px; padding: 20px; - box-shadow: 0 1px 4px rgba(0,0,0,0.06); } - .kpi-card .kpi-label { font-size: 12px; color: #8c95a6; font-weight: 500; - text-transform: uppercase; letter-spacing: 0.5px; } - .kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: #1a1a2e; margin: 4px 0; } - .kpi-card .kpi-change { font-size: 12px; font-weight: 600; } - .kpi-up { color: #10b981; } .kpi-down { color: #ef4444; } - .chart-area { background: #fff; border-radius: 12px; padding: 24px; margin: 16px 0; - box-shadow: 0 1px 4px rgba(0,0,0,0.06); min-height: 200px; } - table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; - background: #fff; border-radius: 10px; overflow: hidden; - box-shadow: 0 1px 4px rgba(0,0,0,0.06); } - th { background: #f7f8fa; text-align: left; padding: 10px 14px; font-weight: 600; - color: #6c7893; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.5px; - border-bottom: 1px solid #edf0f4; } - td { padding: 10px 14px; border-bottom: 1px solid #f3f4f6; } - tr:hover td { background: #f9fafb; } - ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; } - li { margin: 4px 0; } - code { background: #f1f3f5; padding: 2px 7px; border-radius: 5px; font-size: 12px; - font-family: 'JetBrains Mono', Consolas, monospace; } - pre { background: #1a1a2e; color: #c9d1d9; padding: 18px; border-radius: 10px; - overflow-x: auto; font-size: 12px; margin: 14px 0; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 3px solid #4b5efc; padding: 10px 16px; margin: 14px 0; - background: #f0f0ff; border-radius: 0 8px 8px 0; font-size: 13px; } - .status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; - font-size: 11px; font-weight: 600; } - .status-ok { background: #d1fae5; color: #065f46; } - .status-warn { background: #fef3c7; color: #92400e; } - .status-err { background: #fee2e2; color: #991b1b; } - """; - #endregion - - // ════════════════════════════════════════════════════════════════════ - // 공통 CSS 컴포넌트 (모든 무드에 자동 첨부) - // ════════════════════════════════════════════════════════════════════ - - #region Shared — 공통 컴포넌트 - private const string CssShared = """ - - /* ── 목차 (TOC) ── */ - nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px; - padding: 20px 28px; margin: 24px 0 32px; } - nav.toc h2 { font-size: 15px; font-weight: 700; margin: 0 0 12px; padding: 0; border: none; - color: inherit; display: block; background: none; } - nav.toc ul { list-style: none; margin: 0; padding: 0; } - nav.toc li { margin: 4px 0; } - nav.toc li.toc-h3 { padding-left: 18px; } - nav.toc a { text-decoration: none; color: #4b5efc; font-size: 13.5px; } - nav.toc a:hover { text-decoration: underline; } - - /* ── 커버 페이지 ── */ - .cover-page { text-align: center; padding: 80px 40px 60px; margin: -56px -52px 40px; - border-radius: 16px 16px 0 0; position: relative; overflow: hidden; - background: linear-gradient(135deg, #4b5efc 0%, #7c3aed 100%); color: #fff; } - .cover-page h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; color: #fff; - -webkit-text-fill-color: #fff; } - .cover-page .cover-subtitle { font-size: 18px; opacity: 0.9; margin-bottom: 24px; } - .cover-page .cover-meta { font-size: 13px; opacity: 0.7; } - .cover-page .cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5); - margin: 20px auto; border-radius: 2px; } - - /* ── 콜아웃 (callout) ── */ - .callout { border-radius: 8px; padding: 16px 20px; margin: 16px 0; font-size: 14px; - border-left: 4px solid; display: flex; gap: 10px; align-items: flex-start; } - .callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; } - .callout-info { background: #eff6ff; border-color: #3b82f6; color: #1e40af; } - .callout-info::before { content: 'ℹ️'; } - .callout-warning { background: #fffbeb; border-color: #f59e0b; color: #92400e; } - .callout-warning::before { content: '⚠️'; } - .callout-tip { background: #f0fdf4; border-color: #22c55e; color: #166534; } - .callout-tip::before { content: '💡'; } - .callout-danger { background: #fef2f2; border-color: #ef4444; color: #991b1b; } - .callout-danger::before { content: '🚨'; } - .callout-note { background: #f5f3ff; border-color: #8b5cf6; color: #5b21b6; } - .callout-note::before { content: '📝'; } - - /* ── 배지 (badge) — 공통 ── */ - .badge, .tag, .chip { display: inline-block; padding: 3px 10px; border-radius: 20px; - font-size: 11px; font-weight: 600; margin: 2px 4px 2px 0; } - .badge-blue { background: #dbeafe; color: #1e40af; } - .badge-green { background: #d1fae5; color: #065f46; } - .badge-red { background: #fee2e2; color: #991b1b; } - .badge-yellow { background: #fef3c7; color: #92400e; } - .badge-purple { background: #ede9fe; color: #5b21b6; } - .badge-gray { background: #f3f4f6; color: #374151; } - .badge-orange { background: #ffedd5; color: #9a3412; } - - /* ── 하이라이트 박스 ── */ - .highlight-box { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%); - padding: 16px 20px; border-radius: 10px; margin: 16px 0; } - - /* ── CSS 차트 (bar/horizontal) ── */ - .chart-bar { margin: 20px 0; } - .chart-bar .bar-item { display: flex; align-items: center; margin: 6px 0; gap: 10px; } - .chart-bar .bar-label { min-width: 100px; font-size: 13px; text-align: right; flex-shrink: 0; } - .chart-bar .bar-track { flex: 1; background: #e5e7eb; border-radius: 6px; height: 22px; - overflow: hidden; } - .chart-bar .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; - padding: 0 8px; font-size: 11px; font-weight: 600; color: #fff; - transition: width 0.3s ease; min-width: fit-content; } - .bar-fill.blue { background: #3b82f6; } .bar-fill.green { background: #22c55e; } - .bar-fill.red { background: #ef4444; } .bar-fill.yellow { background: #f59e0b; } - .bar-fill.purple { background: #8b5cf6; } .bar-fill.orange { background: #f97316; } - - /* ── CSS 도넛 차트 ── */ - .chart-donut { width: 160px; height: 160px; border-radius: 50%; margin: 20px auto; - background: conic-gradient(var(--seg1-color, #3b82f6) 0% var(--seg1, 0%), - var(--seg2-color, #22c55e) var(--seg1, 0%) var(--seg2, 0%), - var(--seg3-color, #f59e0b) var(--seg2, 0%) var(--seg3, 0%), - var(--seg4-color, #ef4444) var(--seg3, 0%) var(--seg4, 0%), - #e5e7eb var(--seg4, 0%) 100%); - display: flex; align-items: center; justify-content: center; position: relative; } - .chart-donut::after { content: ''; width: 100px; height: 100px; background: #fff; - border-radius: 50%; position: absolute; } - .chart-donut .donut-label { position: absolute; z-index: 1; font-size: 18px; font-weight: 700; } - - /* ── 진행률 바 ── */ - .progress { background: #e5e7eb; border-radius: 8px; height: 10px; margin: 8px 0; - overflow: hidden; } - .progress-fill { height: 100%; border-radius: 8px; background: #3b82f6; } - - /* ── 타임라인 ── */ - .timeline { position: relative; padding-left: 28px; margin: 20px 0; } - .timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; - width: 2px; background: #e5e7eb; } - .timeline-item { position: relative; margin: 16px 0; } - .timeline-item::before { content: ''; position: absolute; left: -24px; top: 5px; - width: 12px; height: 12px; border-radius: 50%; background: #4b5efc; - border: 2px solid #fff; box-shadow: 0 0 0 2px #4b5efc; } - .timeline-item .timeline-date { font-size: 12px; color: #6b7280; font-weight: 600; } - .timeline-item .timeline-content { font-size: 14px; margin-top: 4px; } - - /* ── 섹션 자동 번호 ── */ - body { counter-reset: section; } - h2.numbered { counter-increment: section; counter-reset: subsection; } - h2.numbered::before { content: counter(section) '. '; } - h3.numbered { counter-increment: subsection; } - h3.numbered::before { content: counter(section) '-' counter(subsection) '. '; } - - /* ── 그리드 레이아웃 ── */ - .grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; } - .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 16px 0; } - .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; } - - /* ── 카드 공통 ── */ - .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; - padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); } - .card-header { font-size: 15px; font-weight: 700; margin-bottom: 8px; } - - /* ── 구분선 ── */ - .divider { border: none; border-top: 1px solid #e5e7eb; margin: 32px 0; } - .divider-thick { border: none; border-top: 3px solid #e5e7eb; margin: 40px 0; } - - /* ── 인쇄/PDF 최적화 ── */ - @media print { - body { background: #fff !important; padding: 0 !important; } - .container { box-shadow: none !important; border: none !important; - max-width: none !important; padding: 20px !important; } - .cover-page { break-after: page; } - h2, h3 { break-after: avoid; } - table, figure, .chart-bar, .callout { break-inside: avoid; } - nav.toc { break-after: page; } - a { color: inherit !important; text-decoration: none !important; } - a[href]::after { content: ' (' attr(href) ')'; font-size: 10px; color: #999; } - .no-print { display: none !important; } - } - """; - #endregion } /// 테마 무드 정의. diff --git a/src/AxCopilot/Services/LlmService.ClaudeTools.cs b/src/AxCopilot/Services/LlmService.ClaudeTools.cs new file mode 100644 index 0000000..9c447c0 --- /dev/null +++ b/src/AxCopilot/Services/LlmService.ClaudeTools.cs @@ -0,0 +1,195 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── Claude Function Calling ─────────────────────────────────────── + + private async Task> SendClaudeWithToolsAsync( + List messages, IReadOnlyCollection tools, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = llm.ApiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); + + var body = BuildClaudeToolBody(messages, tools); + var json = JsonSerializer.Serialize(body); + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", "2023-06-01"); + + using var resp = await _http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + + var respJson = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(respJson); + var root = doc.RootElement; + + // 토큰 사용량 + if (root.TryGetProperty("usage", out var usage)) + TryParseClaudeUsageFromElement(usage); + + // 컨텐츠 블록 파싱 + var blocks = new List(); + if (root.TryGetProperty("content", out var content)) + { + foreach (var block in content.EnumerateArray()) + { + var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : ""; + if (type == "text") + { + blocks.Add(new ContentBlock + { + Type = "text", + Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : "" + }); + } + else if (type == "tool_use") + { + blocks.Add(new ContentBlock + { + Type = "tool_use", + ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", + ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", + ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null + }); + } + } + } + + return blocks; + } + + private object BuildClaudeToolBody(List messages, IReadOnlyCollection tools) + { + var llm = _settings.Settings.Llm; + var msgs = new List(); + + foreach (var m in messages) + { + if (m.Role == "system") continue; + + // tool_result 메시지인지 확인 + if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + var root = doc.RootElement; + msgs.Add(new + { + role = "user", + content = new object[] + { + new + { + type = "tool_result", + tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "", + content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : "" + } + } + }); + continue; + } + catch (Exception) { /* 파싱 실패시 일반 메시지로 처리 */ } + } + + // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) + if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception(); + var contentList = new List(); + foreach (var b in blocksArr.EnumerateArray()) + { + var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; + if (bType == "text") + contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); + else if (bType == "tool_use") + contentList.Add(new + { + type = "tool_use", + id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", + name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", + input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } + }); + } + msgs.Add(new { role = "assistant", content = contentList }); + continue; + } + catch (Exception) { /* 파싱 실패시 일반 메시지로 처리 */ } + } + + // Claude Vision: 이미지가 있으면 content를 배열로 변환 + if (m.Images?.Count > 0 && m.Role == "user") + { + var contentParts = new List(); + foreach (var img in m.Images) + contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); + contentParts.Add(new { type = "text", text = m.Content }); + msgs.Add(new { role = m.Role, content = contentParts }); + } + else + { + msgs.Add(new { role = m.Role, content = m.Content }); + } + } + + // 도구 정의 + var toolDefs = tools.Select(t => new + { + name = t.Name, + description = t.Description, + input_schema = new + { + type = "object", + properties = t.Parameters.Properties.ToDictionary( + kv => kv.Key, + kv => BuildPropertySchema(kv.Value, false)), + required = t.Parameters.Required + } + }).ToArray(); + + // 시스템 프롬프트 + var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt; + var activeModel = ResolveModel(); + + if (!string.IsNullOrEmpty(systemPrompt)) + { + return new + { + model = activeModel, + max_tokens = Math.Max(llm.MaxContextTokens, 4096), + temperature = llm.Temperature, + system = systemPrompt, + messages = msgs, + tools = toolDefs, + stream = false + }; + } + + return new + { + model = activeModel, + max_tokens = Math.Max(llm.MaxContextTokens, 4096), + temperature = llm.Temperature, + messages = msgs, + tools = toolDefs, + stream = false + }; + } +} diff --git a/src/AxCopilot/Services/LlmService.GeminiTools.cs b/src/AxCopilot/Services/LlmService.GeminiTools.cs new file mode 100644 index 0000000..b9ba728 --- /dev/null +++ b/src/AxCopilot/Services/LlmService.GeminiTools.cs @@ -0,0 +1,190 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── Gemini Function Calling ─────────────────────────────────────── + + private async Task> SendGeminiWithToolsAsync( + List messages, IReadOnlyCollection tools, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = ResolveApiKeyForService("gemini"); + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); + + var activeModel = ResolveModel(); + var body = BuildGeminiToolBody(messages, tools); + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{activeModel}:generateContent?key={apiKey}"; + var json = JsonSerializer.Serialize(body); + + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync(url, content, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"Gemini API 오류 ({resp.StatusCode}): {errBody}"); + } + + var respJson = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(respJson); + var root = doc.RootElement; + + TryParseGeminiUsage(root); + + var blocks = new List(); + if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0) + { + var firstCandidate = candidates[0]; + if (firstCandidate.TryGetProperty("content", out var contentObj) && + contentObj.TryGetProperty("parts", out var parts)) + { + foreach (var part in parts.EnumerateArray()) + { + if (part.TryGetProperty("text", out var text)) + { + blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" }); + } + else if (part.TryGetProperty("functionCall", out var fc)) + { + blocks.Add(new ContentBlock + { + Type = "tool_use", + ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "", + ToolId = Guid.NewGuid().ToString("N")[..12], + ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null + }); + } + } + } + } + + return blocks; + } + + private object BuildGeminiToolBody(List messages, IReadOnlyCollection tools) + { + var contents = new List(); + foreach (var m in messages) + { + if (m.Role == "system") continue; + var role = m.Role == "assistant" ? "model" : "user"; + + // tool_result 메시지 처리 + if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + var root = doc.RootElement; + var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : ""; + var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : ""; + contents.Add(new + { + role = "function", + parts = new object[] + { + new + { + functionResponse = new + { + name = toolName, + response = new { result = toolContent } + } + } + } + }); + continue; + } + catch (Exception) { } + } + + // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) + if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) + { + var parts = new List(); + foreach (var b in blocksArr.EnumerateArray()) + { + var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; + if (bType == "text") + parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); + else if (bType == "tool_use") + parts.Add(new + { + functionCall = new + { + name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", + args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } + } + }); + } + contents.Add(new { role = "model", parts }); + continue; + } + } + catch (Exception) { } + } + + // Gemini Vision: 이미지가 있으면 parts에 inlineData 추가 + if (m.Images?.Count > 0 && m.Role == "user") + { + var imgParts = new List { new { text = m.Content } }; + foreach (var img in m.Images) + imgParts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); + contents.Add(new { role, parts = imgParts }); + } + else + { + contents.Add(new { role, parts = new[] { new { text = m.Content } } }); + } + } + + // 도구 정의 (Gemini function_declarations 형식) + var funcDecls = tools.Select(t => new + { + name = t.Name, + description = t.Description, + parameters = new + { + type = "OBJECT", + properties = t.Parameters.Properties.ToDictionary( + kv => kv.Key, + kv => BuildPropertySchema(kv.Value, true)), + required = t.Parameters.Required + } + }).ToArray(); + + var systemInstruction = messages.FirstOrDefault(m => m.Role == "system"); + + var body = new Dictionary + { + ["contents"] = contents, + ["tools"] = new[] { new { function_declarations = funcDecls } }, + ["generationConfig"] = new + { + temperature = _settings.Settings.Llm.Temperature, + maxOutputTokens = _settings.Settings.Llm.MaxContextTokens, + } + }; + + if (systemInstruction != null) + { + body["systemInstruction"] = new + { + parts = new[] { new { text = systemInstruction.Content } } + }; + } + + return body; + } +} diff --git a/src/AxCopilot/Services/LlmService.OpenAiTools.cs b/src/AxCopilot/Services/LlmService.OpenAiTools.cs new file mode 100644 index 0000000..9d870d5 --- /dev/null +++ b/src/AxCopilot/Services/LlmService.OpenAiTools.cs @@ -0,0 +1,251 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── OpenAI Compatible (Ollama / vLLM) Function Calling ────────── + + private async Task> SendOpenAiWithToolsAsync( + List messages, IReadOnlyCollection tools, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var activeService = ResolveService(); + var body = BuildOpenAiToolBody(messages, tools); + + // 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo) + var (resolvedEp, _) = ResolveServerInfo(); + var endpoint = string.IsNullOrEmpty(resolvedEp) + ? ResolveEndpointForService(activeService) + : resolvedEp; + + var url = activeService.ToLowerInvariant() == "ollama" + ? endpoint.TrimEnd('/') + "/api/chat" + : endpoint.TrimEnd('/') + "/v1/chat/completions"; + var json = JsonSerializer.Serialize(body); + + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + // CP4D 또는 Bearer 인증 적용 + await ApplyAuthHeaderAsync(req, ct); + + using var resp = await _http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + var detail = ExtractErrorDetail(errBody); + LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); + + // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 + if ((int)resp.StatusCode == 400) + throw new ToolCallNotSupportedException( + $"{activeService} API 오류 ({resp.StatusCode}): {detail}"); + + throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); + } + + var respJson = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(respJson); + var root = doc.RootElement; + + TryParseOpenAiUsage(root); + + var blocks = new List(); + + // Ollama 형식: root.message + // OpenAI 형식: root.choices[0].message + JsonElement message; + if (root.TryGetProperty("message", out var ollamaMsg)) + message = ollamaMsg; + else if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) + message = choices[0].TryGetProperty("message", out var choiceMsg) ? choiceMsg : default; + else + return blocks; + + // 텍스트 응답 + if (message.TryGetProperty("content", out var content)) + { + var text = content.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + blocks.Add(new ContentBlock { Type = "text", Text = text }); + } + + // 도구 호출 (tool_calls 배열) + if (message.TryGetProperty("tool_calls", out var toolCalls)) + { + foreach (var tc in toolCalls.EnumerateArray()) + { + if (!tc.TryGetProperty("function", out var func)) continue; + + // arguments: 표준(OpenAI)은 JSON 문자열, Ollama/qwen 등은 JSON 객체를 직접 반환하기도 함 + JsonElement? parsedArgs = null; + if (func.TryGetProperty("arguments", out var argsEl)) + { + if (argsEl.ValueKind == JsonValueKind.String) + { + // 표준: 문자열로 감싸진 JSON → 파싱 + try + { + using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}"); + parsedArgs = argsDoc.RootElement.Clone(); + } + catch (Exception) { parsedArgs = null; } + } + else if (argsEl.ValueKind == JsonValueKind.Object || argsEl.ValueKind == JsonValueKind.Array) + { + // Ollama/qwen 방식: 이미 JSON 객체 — 그대로 사용 + parsedArgs = argsEl.Clone(); + } + } + + blocks.Add(new ContentBlock + { + Type = "tool_use", + ToolName = func.TryGetProperty("name", out var fnm) ? fnm.GetString() ?? "" : "", + ToolId = tc.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], + ToolInput = parsedArgs, + }); + } + } + + return blocks; + } + + private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools) + { + var llm = _settings.Settings.Llm; + var msgs = new List(); + + foreach (var m in messages) + { + // tool_result 메시지 → OpenAI tool 응답 형식 + if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + var root = doc.RootElement; + msgs.Add(new + { + role = "tool", + tool_call_id = root.GetProperty("tool_use_id").GetString(), + content = root.GetProperty("content").GetString(), + }); + continue; + } + catch (Exception) { } + } + + // assistant 메시지에 tool_use 블록이 포함된 경우 + if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) + { + try + { + using var doc = JsonDocument.Parse(m.Content); + var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); + var textContent = ""; + var toolCallsList = new List(); + foreach (var b in blocksArr.EnumerateArray()) + { + var bType = b.GetProperty("type").GetString(); + if (bType == "text") + textContent = b.GetProperty("text").GetString() ?? ""; + else if (bType == "tool_use") + { + var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}"; + toolCallsList.Add(new + { + id = b.GetProperty("id").GetString() ?? "", + type = "function", + function = new + { + name = b.GetProperty("name").GetString() ?? "", + arguments = argsJson, + } + }); + } + } + msgs.Add(new + { + role = "assistant", + content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent, + tool_calls = toolCallsList, + }); + continue; + } + catch (Exception) { } + } + + // ── 이미지 첨부 (Vision) ── + if (m.Role == "user" && m.Images?.Count > 0) + { + var contentParts = new List(); + foreach (var img in m.Images) + contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } }); + contentParts.Add(new { type = "text", text = m.Content }); + msgs.Add(new { role = m.Role, content = contentParts }); + } + else + { + msgs.Add(new { role = m.Role, content = m.Content }); + } + } + + // OpenAI 도구 정의 + var toolDefs = tools.Select(t => + { + // parameters 객체: required가 비어있으면 생략 (일부 Ollama 버전 호환) + var paramDict = new Dictionary + { + ["type"] = "object", + ["properties"] = t.Parameters.Properties.ToDictionary( + kv => kv.Key, + kv => BuildPropertySchema(kv.Value, false)), + }; + if (t.Parameters.Required is { Count: > 0 }) + paramDict["required"] = t.Parameters.Required; + + return new + { + type = "function", + function = new + { + name = t.Name, + description = t.Description, + parameters = paramDict, + } + }; + }).ToArray(); + + var activeService = ResolveService(); + var activeModel = ResolveModel(); + var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); + if (isOllama) + { + return new + { + model = activeModel, + messages = msgs, + tools = toolDefs, + stream = false, + options = new { temperature = llm.Temperature } + }; + } + + return new + { + model = activeModel, + messages = msgs, + tools = toolDefs, + stream = false, + temperature = llm.Temperature, + max_tokens = llm.MaxContextTokens, + }; + } +} diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index 6faa092..a56276c 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -1,6 +1,3 @@ -using System.Net.Http; -using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services.Agent; @@ -60,612 +57,6 @@ public partial class LlmService }; } - // ─── Claude Function Calling ─────────────────────────────────────── - - private async Task> SendClaudeWithToolsAsync( - List messages, IReadOnlyCollection tools, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = llm.ApiKey; - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); - - var body = BuildClaudeToolBody(messages, tools); - var json = JsonSerializer.Serialize(body); - - using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - req.Headers.Add("x-api-key", apiKey); - req.Headers.Add("anthropic-version", "2023-06-01"); - - using var resp = await _http.SendAsync(req, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - - var respJson = await resp.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(respJson); - var root = doc.RootElement; - - // 토큰 사용량 - if (root.TryGetProperty("usage", out var usage)) - TryParseClaudeUsageFromElement(usage); - - // 컨텐츠 블록 파싱 - var blocks = new List(); - if (root.TryGetProperty("content", out var content)) - { - foreach (var block in content.EnumerateArray()) - { - var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : ""; - if (type == "text") - { - blocks.Add(new ContentBlock - { - Type = "text", - Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : "" - }); - } - else if (type == "tool_use") - { - blocks.Add(new ContentBlock - { - Type = "tool_use", - ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", - ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", - ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null - }); - } - } - } - - return blocks; - } - - private object BuildClaudeToolBody(List messages, IReadOnlyCollection tools) - { - var llm = _settings.Settings.Llm; - var msgs = new List(); - - foreach (var m in messages) - { - if (m.Role == "system") continue; - - // tool_result 메시지인지 확인 - if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - var root = doc.RootElement; - msgs.Add(new - { - role = "user", - content = new object[] - { - new - { - type = "tool_result", - tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "", - content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : "" - } - } - }); - continue; - } - catch (Exception) { /* 파싱 실패시 일반 메시지로 처리 */ } - } - - // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) - if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception(); - var contentList = new List(); - foreach (var b in blocksArr.EnumerateArray()) - { - var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; - if (bType == "text") - contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); - else if (bType == "tool_use") - contentList.Add(new - { - type = "tool_use", - id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", - name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", - input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } - }); - } - msgs.Add(new { role = "assistant", content = contentList }); - continue; - } - catch (Exception) { /* 파싱 실패시 일반 메시지로 처리 */ } - } - - // Claude Vision: 이미지가 있으면 content를 배열로 변환 - if (m.Images?.Count > 0 && m.Role == "user") - { - var contentParts = new List(); - foreach (var img in m.Images) - contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); - contentParts.Add(new { type = "text", text = m.Content }); - msgs.Add(new { role = m.Role, content = contentParts }); - } - else - { - msgs.Add(new { role = m.Role, content = m.Content }); - } - } - - // 도구 정의 - var toolDefs = tools.Select(t => new - { - name = t.Name, - description = t.Description, - input_schema = new - { - type = "object", - properties = t.Parameters.Properties.ToDictionary( - kv => kv.Key, - kv => BuildPropertySchema(kv.Value, false)), - required = t.Parameters.Required - } - }).ToArray(); - - // 시스템 프롬프트 - var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt; - var activeModel = ResolveModel(); - - if (!string.IsNullOrEmpty(systemPrompt)) - { - return new - { - model = activeModel, - max_tokens = Math.Max(llm.MaxContextTokens, 4096), - temperature = llm.Temperature, - system = systemPrompt, - messages = msgs, - tools = toolDefs, - stream = false - }; - } - - return new - { - model = activeModel, - max_tokens = Math.Max(llm.MaxContextTokens, 4096), - temperature = llm.Temperature, - messages = msgs, - tools = toolDefs, - stream = false - }; - } - - // ─── Gemini Function Calling ─────────────────────────────────────── - - private async Task> SendGeminiWithToolsAsync( - List messages, IReadOnlyCollection tools, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = ResolveApiKeyForService("gemini"); - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); - - var activeModel = ResolveModel(); - var body = BuildGeminiToolBody(messages, tools); - var url = $"https://generativelanguage.googleapis.com/v1beta/models/{activeModel}:generateContent?key={apiKey}"; - var json = JsonSerializer.Serialize(body); - - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var resp = await _http.PostAsync(url, content, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException($"Gemini API 오류 ({resp.StatusCode}): {errBody}"); - } - - var respJson = await resp.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(respJson); - var root = doc.RootElement; - - TryParseGeminiUsage(root); - - var blocks = new List(); - if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0) - { - var firstCandidate = candidates[0]; - if (firstCandidate.TryGetProperty("content", out var contentObj) && - contentObj.TryGetProperty("parts", out var parts)) - { - foreach (var part in parts.EnumerateArray()) - { - if (part.TryGetProperty("text", out var text)) - { - blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" }); - } - else if (part.TryGetProperty("functionCall", out var fc)) - { - blocks.Add(new ContentBlock - { - Type = "tool_use", - ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "", - ToolId = Guid.NewGuid().ToString("N")[..12], - ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null - }); - } - } - } - } - - return blocks; - } - - private object BuildGeminiToolBody(List messages, IReadOnlyCollection tools) - { - var contents = new List(); - foreach (var m in messages) - { - if (m.Role == "system") continue; - var role = m.Role == "assistant" ? "model" : "user"; - - // tool_result 메시지 처리 - if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - var root = doc.RootElement; - var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : ""; - var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : ""; - contents.Add(new - { - role = "function", - parts = new object[] - { - new - { - functionResponse = new - { - name = toolName, - response = new { result = toolContent } - } - } - } - }); - continue; - } - catch (Exception) { } - } - - // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) - if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) - { - var parts = new List(); - foreach (var b in blocksArr.EnumerateArray()) - { - var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; - if (bType == "text") - parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); - else if (bType == "tool_use") - parts.Add(new - { - functionCall = new - { - name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", - args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } - } - }); - } - contents.Add(new { role = "model", parts }); - continue; - } - } - catch (Exception) { } - } - - // Gemini Vision: 이미지가 있으면 parts에 inlineData 추가 - if (m.Images?.Count > 0 && m.Role == "user") - { - var imgParts = new List { new { text = m.Content } }; - foreach (var img in m.Images) - imgParts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); - contents.Add(new { role, parts = imgParts }); - } - else - { - contents.Add(new { role, parts = new[] { new { text = m.Content } } }); - } - } - - // 도구 정의 (Gemini function_declarations 형식) - var funcDecls = tools.Select(t => new - { - name = t.Name, - description = t.Description, - parameters = new - { - type = "OBJECT", - properties = t.Parameters.Properties.ToDictionary( - kv => kv.Key, - kv => BuildPropertySchema(kv.Value, true)), - required = t.Parameters.Required - } - }).ToArray(); - - var systemInstruction = messages.FirstOrDefault(m => m.Role == "system"); - - var body = new Dictionary - { - ["contents"] = contents, - ["tools"] = new[] { new { function_declarations = funcDecls } }, - ["generationConfig"] = new - { - temperature = _settings.Settings.Llm.Temperature, - maxOutputTokens = _settings.Settings.Llm.MaxContextTokens, - } - }; - - if (systemInstruction != null) - { - body["systemInstruction"] = new - { - parts = new[] { new { text = systemInstruction.Content } } - }; - } - - return body; - } - - // ─── OpenAI Compatible (Ollama / vLLM) Function Calling ────────── - - private async Task> SendOpenAiWithToolsAsync( - List messages, IReadOnlyCollection tools, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var activeService = ResolveService(); - var body = BuildOpenAiToolBody(messages, tools); - - // 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo) - var (resolvedEp, _) = ResolveServerInfo(); - var endpoint = string.IsNullOrEmpty(resolvedEp) - ? ResolveEndpointForService(activeService) - : resolvedEp; - - var url = activeService.ToLowerInvariant() == "ollama" - ? endpoint.TrimEnd('/') + "/api/chat" - : endpoint.TrimEnd('/') + "/v1/chat/completions"; - var json = JsonSerializer.Serialize(body); - - using var req = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - // CP4D 또는 Bearer 인증 적용 - await ApplyAuthHeaderAsync(req, ct); - - using var resp = await _http.SendAsync(req, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - var detail = ExtractErrorDetail(errBody); - LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); - - // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 - if ((int)resp.StatusCode == 400) - throw new ToolCallNotSupportedException( - $"{activeService} API 오류 ({resp.StatusCode}): {detail}"); - - throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); - } - - var respJson = await resp.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(respJson); - var root = doc.RootElement; - - TryParseOpenAiUsage(root); - - var blocks = new List(); - - // Ollama 형식: root.message - // OpenAI 형식: root.choices[0].message - JsonElement message; - if (root.TryGetProperty("message", out var ollamaMsg)) - message = ollamaMsg; - else if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) - message = choices[0].TryGetProperty("message", out var choiceMsg) ? choiceMsg : default; - else - return blocks; - - // 텍스트 응답 - if (message.TryGetProperty("content", out var content)) - { - var text = content.GetString(); - if (!string.IsNullOrWhiteSpace(text)) - blocks.Add(new ContentBlock { Type = "text", Text = text }); - } - - // 도구 호출 (tool_calls 배열) - if (message.TryGetProperty("tool_calls", out var toolCalls)) - { - foreach (var tc in toolCalls.EnumerateArray()) - { - if (!tc.TryGetProperty("function", out var func)) continue; - - // arguments: 표준(OpenAI)은 JSON 문자열, Ollama/qwen 등은 JSON 객체를 직접 반환하기도 함 - JsonElement? parsedArgs = null; - if (func.TryGetProperty("arguments", out var argsEl)) - { - if (argsEl.ValueKind == JsonValueKind.String) - { - // 표준: 문자열로 감싸진 JSON → 파싱 - try - { - using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}"); - parsedArgs = argsDoc.RootElement.Clone(); - } - catch (Exception) { parsedArgs = null; } - } - else if (argsEl.ValueKind == JsonValueKind.Object || argsEl.ValueKind == JsonValueKind.Array) - { - // Ollama/qwen 방식: 이미 JSON 객체 — 그대로 사용 - parsedArgs = argsEl.Clone(); - } - } - - blocks.Add(new ContentBlock - { - Type = "tool_use", - ToolName = func.TryGetProperty("name", out var fnm) ? fnm.GetString() ?? "" : "", - ToolId = tc.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], - ToolInput = parsedArgs, - }); - } - } - - return blocks; - } - - private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools) - { - var llm = _settings.Settings.Llm; - var msgs = new List(); - - foreach (var m in messages) - { - // tool_result 메시지 → OpenAI tool 응답 형식 - if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - var root = doc.RootElement; - msgs.Add(new - { - role = "tool", - tool_call_id = root.GetProperty("tool_use_id").GetString(), - content = root.GetProperty("content").GetString(), - }); - continue; - } - catch (Exception) { } - } - - // assistant 메시지에 tool_use 블록이 포함된 경우 - if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) - { - try - { - using var doc = JsonDocument.Parse(m.Content); - var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); - var textContent = ""; - var toolCallsList = new List(); - foreach (var b in blocksArr.EnumerateArray()) - { - var bType = b.GetProperty("type").GetString(); - if (bType == "text") - textContent = b.GetProperty("text").GetString() ?? ""; - else if (bType == "tool_use") - { - var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}"; - toolCallsList.Add(new - { - id = b.GetProperty("id").GetString() ?? "", - type = "function", - function = new - { - name = b.GetProperty("name").GetString() ?? "", - arguments = argsJson, - } - }); - } - } - msgs.Add(new - { - role = "assistant", - content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent, - tool_calls = toolCallsList, - }); - continue; - } - catch (Exception) { } - } - - // ── 이미지 첨부 (Vision) ── - if (m.Role == "user" && m.Images?.Count > 0) - { - var contentParts = new List(); - foreach (var img in m.Images) - contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } }); - contentParts.Add(new { type = "text", text = m.Content }); - msgs.Add(new { role = m.Role, content = contentParts }); - } - else - { - msgs.Add(new { role = m.Role, content = m.Content }); - } - } - - // OpenAI 도구 정의 - var toolDefs = tools.Select(t => - { - // parameters 객체: required가 비어있으면 생략 (일부 Ollama 버전 호환) - var paramDict = new Dictionary - { - ["type"] = "object", - ["properties"] = t.Parameters.Properties.ToDictionary( - kv => kv.Key, - kv => BuildPropertySchema(kv.Value, false)), - }; - if (t.Parameters.Required is { Count: > 0 }) - paramDict["required"] = t.Parameters.Required; - - return new - { - type = "function", - function = new - { - name = t.Name, - description = t.Description, - parameters = paramDict, - } - }; - }).ToArray(); - - var activeService = ResolveService(); - var activeModel = ResolveModel(); - var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); - if (isOllama) - { - return new - { - model = activeModel, - messages = msgs, - tools = toolDefs, - stream = false, - options = new { temperature = llm.Temperature } - }; - } - - return new - { - model = activeModel, - messages = msgs, - tools = toolDefs, - stream = false, - temperature = llm.Temperature, - max_tokens = llm.MaxContextTokens, - }; - } - // ─── 공통 헬퍼 ───────────────────────────────────────────────────── /// ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함. diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs b/src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs new file mode 100644 index 0000000..5f6bb66 --- /dev/null +++ b/src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs @@ -0,0 +1,417 @@ +using System.Collections.ObjectModel; +using AxCopilot.Models; + +namespace AxCopilot.ViewModels; + +public partial class SettingsViewModel +{ + /// CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근. + public Models.CodeSettings Code => _service.Settings.Llm.Code; + + // ─── 등록 모델 목록 ─────────────────────────────────────────────────── + public ObservableCollection RegisteredModels { get; } = new(); + + public string LlmService + { + get => _llmService; + set { _llmService = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsInternalService)); OnPropertyChanged(nameof(IsExternalService)); OnPropertyChanged(nameof(NeedsEndpoint)); OnPropertyChanged(nameof(NeedsApiKey)); OnPropertyChanged(nameof(IsGeminiSelected)); OnPropertyChanged(nameof(IsClaudeSelected)); } + } + public bool IsInternalService => _llmService is "ollama" or "vllm"; + public bool IsExternalService => _llmService is "gemini" or "claude"; + public bool NeedsEndpoint => _llmService is "ollama" or "vllm"; + public bool NeedsApiKey => _llmService is not "ollama"; + public bool IsGeminiSelected => _llmService == "gemini"; + public bool IsClaudeSelected => _llmService == "claude"; + + // ── Ollama 설정 ── + public string OllamaEndpoint { get => _ollamaEndpoint; set { _ollamaEndpoint = value; OnPropertyChanged(); } } + public string OllamaApiKey { get => _ollamaApiKey; set { _ollamaApiKey = value; OnPropertyChanged(); } } + public string OllamaModel { get => _ollamaModel; set { _ollamaModel = value; OnPropertyChanged(); } } + + // ── vLLM 설정 ── + public string VllmEndpoint { get => _vllmEndpoint; set { _vllmEndpoint = value; OnPropertyChanged(); } } + public string VllmApiKey { get => _vllmApiKey; set { _vllmApiKey = value; OnPropertyChanged(); } } + public string VllmModel { get => _vllmModel; set { _vllmModel = value; OnPropertyChanged(); } } + + // ── Gemini 설정 ── + public string GeminiApiKey { get => _geminiApiKey; set { _geminiApiKey = value; OnPropertyChanged(); } } + public string GeminiModel { get => _geminiModel; set { _geminiModel = value; OnPropertyChanged(); } } + + // ── Claude 설정 ── + public string ClaudeApiKey { get => _claudeApiKey; set { _claudeApiKey = value; OnPropertyChanged(); } } + public string ClaudeModel { get => _claudeModel; set { _claudeModel = value; OnPropertyChanged(); } } + + // ── 공통 응답 설정 ── + public bool LlmStreaming + { + get => _llmStreaming; + set { _llmStreaming = value; OnPropertyChanged(); } + } + public int LlmMaxContextTokens + { + get => _llmMaxContextTokens; + set { _llmMaxContextTokens = value; OnPropertyChanged(); } + } + public int LlmRetentionDays + { + get => _llmRetentionDays; + set { _llmRetentionDays = value; OnPropertyChanged(); } + } + public double LlmTemperature + { + get => _llmTemperature; + set { _llmTemperature = Math.Round(Math.Clamp(value, 0.0, 2.0), 1); OnPropertyChanged(); } + } + + // 에이전트 기본 파일 접근 권한 + private string _defaultAgentPermission; + public string DefaultAgentPermission + { + get => _defaultAgentPermission; + set { _defaultAgentPermission = value; OnPropertyChanged(); } + } + + // ── 코워크/에이전트 고급 설정 ── + private string _defaultOutputFormat; + public string DefaultOutputFormat + { + get => _defaultOutputFormat; + set { _defaultOutputFormat = value; OnPropertyChanged(); } + } + + private string _autoPreview; + public string AutoPreview + { + get => _autoPreview; + set { _autoPreview = value; OnPropertyChanged(); } + } + + private int _maxAgentIterations; + public int MaxAgentIterations + { + get => _maxAgentIterations; + set { _maxAgentIterations = Math.Clamp(value, 1, 100); OnPropertyChanged(); } + } + + private int _maxRetryOnError; + public int MaxRetryOnError + { + get => _maxRetryOnError; + set { _maxRetryOnError = Math.Clamp(value, 0, 10); OnPropertyChanged(); } + } + + private string _agentLogLevel; + public string AgentLogLevel + { + get => _agentLogLevel; + set { _agentLogLevel = value; OnPropertyChanged(); } + } + + private string _agentDecisionLevel = "detailed"; + public string AgentDecisionLevel + { + get => _agentDecisionLevel; + set { _agentDecisionLevel = value; OnPropertyChanged(); } + } + + private string _planMode = "off"; + public string PlanMode + { + get => _planMode; + set { _planMode = value; OnPropertyChanged(); } + } + + private bool _enableMultiPassDocument; + public bool EnableMultiPassDocument + { + get => _enableMultiPassDocument; + set { _enableMultiPassDocument = value; OnPropertyChanged(); } + } + + private bool _enableCoworkVerification; + public bool EnableCoworkVerification + { + get => _enableCoworkVerification; + set { _enableCoworkVerification = value; OnPropertyChanged(); } + } + + private bool _enableFilePathHighlight = true; + public bool EnableFilePathHighlight + { + get => _enableFilePathHighlight; + set { _enableFilePathHighlight = value; OnPropertyChanged(); } + } + + private string _folderDataUsage; + public string FolderDataUsage + { + get => _folderDataUsage; + set { _folderDataUsage = value; OnPropertyChanged(); } + } + + // ── 모델 폴백 + 보안 + MCP ── + private bool _enableAuditLog; + public bool EnableAuditLog + { + get => _enableAuditLog; + set { _enableAuditLog = value; OnPropertyChanged(); } + } + + private bool _enableAgentMemory; + public bool EnableAgentMemory + { + get => _enableAgentMemory; + set { _enableAgentMemory = value; OnPropertyChanged(); } + } + + private bool _enableProjectRules = true; + public bool EnableProjectRules + { + get => _enableProjectRules; + set { _enableProjectRules = value; OnPropertyChanged(); } + } + + private int _maxMemoryEntries; + public int MaxMemoryEntries + { + get => _maxMemoryEntries; + set { _maxMemoryEntries = value; OnPropertyChanged(); } + } + + // ── 이미지 입력 (멀티모달) ── + private bool _enableImageInput = true; + public bool EnableImageInput + { + get => _enableImageInput; + set { _enableImageInput = value; OnPropertyChanged(); } + } + + private int _maxImageSizeKb = 5120; + public int MaxImageSizeKb + { + get => _maxImageSizeKb; + set { _maxImageSizeKb = value; OnPropertyChanged(); } + } + + // ── 자동 모델 라우팅 ── + private bool _enableAutoRouter; + public bool EnableAutoRouter + { + get => _enableAutoRouter; + set { _enableAutoRouter = value; OnPropertyChanged(); } + } + + private double _autoRouterConfidence = 0.7; + public double AutoRouterConfidence + { + get => _autoRouterConfidence; + set { _autoRouterConfidence = value; OnPropertyChanged(); } + } + + // ── 에이전트 훅 시스템 ── + private bool _enableToolHooks = true; + public bool EnableToolHooks + { + get => _enableToolHooks; + set { _enableToolHooks = value; OnPropertyChanged(); } + } + + private int _toolHookTimeoutMs = 10000; + public int ToolHookTimeoutMs + { + get => _toolHookTimeoutMs; + set { _toolHookTimeoutMs = value; OnPropertyChanged(); } + } + + // ── 스킬 시스템 ── + private bool _enableSkillSystem = true; + public bool EnableSkillSystem + { + get => _enableSkillSystem; + set { _enableSkillSystem = value; OnPropertyChanged(); } + } + + private string _skillsFolderPath = ""; + public string SkillsFolderPath + { + get => _skillsFolderPath; + set { _skillsFolderPath = value; OnPropertyChanged(); } + } + + private int _slashPopupPageSize = 6; + public int SlashPopupPageSize + { + get => _slashPopupPageSize; + set { _slashPopupPageSize = Math.Clamp(value, 3, 10); OnPropertyChanged(); } + } + + // ── 드래그&드롭 AI ── + private bool _enableDragDropAiActions = true; + public bool EnableDragDropAiActions + { + get => _enableDragDropAiActions; + set { _enableDragDropAiActions = value; OnPropertyChanged(); } + } + + private bool _dragDropAutoSend; + public bool DragDropAutoSend + { + get => _dragDropAutoSend; + set { _dragDropAutoSend = value; OnPropertyChanged(); } + } + + // ── 코드 리뷰 ── + private bool _enableCodeReview = true; + public bool EnableCodeReview + { + get => _enableCodeReview; + set { _enableCodeReview = value; OnPropertyChanged(); } + } + + // ── 시각 효과 + 알림 + 개발자 모드 (공통) ── + private bool _enableChatRainbowGlow; + public bool EnableChatRainbowGlow + { + get => _enableChatRainbowGlow; + set { _enableChatRainbowGlow = value; OnPropertyChanged(); } + } + + private bool _notifyOnComplete; + public bool NotifyOnComplete + { + get => _notifyOnComplete; + set { _notifyOnComplete = value; OnPropertyChanged(); } + } + + private bool _showTips; + public bool ShowTips + { + get => _showTips; + set { _showTips = value; OnPropertyChanged(); } + } + + private bool _devMode; + public bool DevMode + { + get => _devMode; + set { _devMode = value; OnPropertyChanged(); } + } + + private bool _devModeStepApproval; + public bool DevModeStepApproval + { + get => _devModeStepApproval; + set { _devModeStepApproval = value; OnPropertyChanged(); } + } + + private bool _workflowVisualizer; + public bool WorkflowVisualizer + { + get => _workflowVisualizer; + set { _workflowVisualizer = value; OnPropertyChanged(); } + } + + private bool _freeTierMode; + public bool FreeTierMode + { + get => _freeTierMode; + set { _freeTierMode = value; OnPropertyChanged(); } + } + + private int _freeTierDelaySeconds = 4; + public int FreeTierDelaySeconds + { + get => _freeTierDelaySeconds; + set { _freeTierDelaySeconds = value; OnPropertyChanged(); } + } + + private bool _showTotalCallStats; + public bool ShowTotalCallStats + { + get => _showTotalCallStats; + set { _showTotalCallStats = value; OnPropertyChanged(); } + } + + private string _defaultMood = "modern"; + public string DefaultMood + { + get => _defaultMood; + set { _defaultMood = value; OnPropertyChanged(); } + } + + // 차단 경로/확장자 (읽기 전용 UI) + public ObservableCollection BlockedPaths { get; } = new(); + public ObservableCollection BlockedExtensions { get; } = new(); + + public string Hotkey + { + get => _hotkey; + set { _hotkey = value; OnPropertyChanged(); } + } + + public int MaxResults + { + get => _maxResults; + set { _maxResults = value; OnPropertyChanged(); } + } + + public double Opacity + { + get => _opacity; + set { _opacity = value; OnPropertyChanged(); OnPropertyChanged(nameof(OpacityPercent)); } + } + + public int OpacityPercent => (int)Math.Round(_opacity * 100); + + public string SelectedThemeKey + { + get => _selectedThemeKey; + set + { + _selectedThemeKey = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsCustomTheme)); + foreach (var card in ThemeCards) + card.IsSelected = card.Key == value; + } + } + + public bool IsCustomTheme => _selectedThemeKey == "custom"; + + public string LauncherPosition + { + get => _launcherPosition; + set { _launcherPosition = value; OnPropertyChanged(); } + } + + public string WebSearchEngine + { + get => _webSearchEngine; + set { _webSearchEngine = value; OnPropertyChanged(); } + } + + public bool SnippetAutoExpand + { + get => _snippetAutoExpand; + set { _snippetAutoExpand = value; OnPropertyChanged(); } + } + + public string Language + { + get => _language; + set { _language = value; OnPropertyChanged(); } + } + + public string IndexSpeed + { + get => _indexSpeed; + set { _indexSpeed = value; OnPropertyChanged(); OnPropertyChanged(nameof(IndexSpeedHint)); } + } + + public string IndexSpeedHint => _indexSpeed switch + { + "fast" => "CPU 사용률이 높아질 수 있습니다. 고성능 PC에 권장합니다.", + "slow" => "인덱싱이 오래 걸리지만 PC 성능에 영향을 주지 않습니다.", + _ => "일반적인 PC에 적합한 균형 설정입니다.", + }; +} diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs index 7ef438b..b1e5066 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs @@ -5,416 +5,6 @@ namespace AxCopilot.ViewModels; public partial class SettingsViewModel { - /// CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근. - public Models.CodeSettings Code => _service.Settings.Llm.Code; - - // ─── 등록 모델 목록 ─────────────────────────────────────────────────── - public ObservableCollection RegisteredModels { get; } = new(); - - public string LlmService - { - get => _llmService; - set { _llmService = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsInternalService)); OnPropertyChanged(nameof(IsExternalService)); OnPropertyChanged(nameof(NeedsEndpoint)); OnPropertyChanged(nameof(NeedsApiKey)); OnPropertyChanged(nameof(IsGeminiSelected)); OnPropertyChanged(nameof(IsClaudeSelected)); } - } - public bool IsInternalService => _llmService is "ollama" or "vllm"; - public bool IsExternalService => _llmService is "gemini" or "claude"; - public bool NeedsEndpoint => _llmService is "ollama" or "vllm"; - public bool NeedsApiKey => _llmService is not "ollama"; - public bool IsGeminiSelected => _llmService == "gemini"; - public bool IsClaudeSelected => _llmService == "claude"; - - // ── Ollama 설정 ── - public string OllamaEndpoint { get => _ollamaEndpoint; set { _ollamaEndpoint = value; OnPropertyChanged(); } } - public string OllamaApiKey { get => _ollamaApiKey; set { _ollamaApiKey = value; OnPropertyChanged(); } } - public string OllamaModel { get => _ollamaModel; set { _ollamaModel = value; OnPropertyChanged(); } } - - // ── vLLM 설정 ── - public string VllmEndpoint { get => _vllmEndpoint; set { _vllmEndpoint = value; OnPropertyChanged(); } } - public string VllmApiKey { get => _vllmApiKey; set { _vllmApiKey = value; OnPropertyChanged(); } } - public string VllmModel { get => _vllmModel; set { _vllmModel = value; OnPropertyChanged(); } } - - // ── Gemini 설정 ── - public string GeminiApiKey { get => _geminiApiKey; set { _geminiApiKey = value; OnPropertyChanged(); } } - public string GeminiModel { get => _geminiModel; set { _geminiModel = value; OnPropertyChanged(); } } - - // ── Claude 설정 ── - public string ClaudeApiKey { get => _claudeApiKey; set { _claudeApiKey = value; OnPropertyChanged(); } } - public string ClaudeModel { get => _claudeModel; set { _claudeModel = value; OnPropertyChanged(); } } - - // ── 공통 응답 설정 ── - public bool LlmStreaming - { - get => _llmStreaming; - set { _llmStreaming = value; OnPropertyChanged(); } - } - public int LlmMaxContextTokens - { - get => _llmMaxContextTokens; - set { _llmMaxContextTokens = value; OnPropertyChanged(); } - } - public int LlmRetentionDays - { - get => _llmRetentionDays; - set { _llmRetentionDays = value; OnPropertyChanged(); } - } - public double LlmTemperature - { - get => _llmTemperature; - set { _llmTemperature = Math.Round(Math.Clamp(value, 0.0, 2.0), 1); OnPropertyChanged(); } - } - - // 에이전트 기본 파일 접근 권한 - private string _defaultAgentPermission; - public string DefaultAgentPermission - { - get => _defaultAgentPermission; - set { _defaultAgentPermission = value; OnPropertyChanged(); } - } - - // ── 코워크/에이전트 고급 설정 ── - private string _defaultOutputFormat; - public string DefaultOutputFormat - { - get => _defaultOutputFormat; - set { _defaultOutputFormat = value; OnPropertyChanged(); } - } - - private string _autoPreview; - public string AutoPreview - { - get => _autoPreview; - set { _autoPreview = value; OnPropertyChanged(); } - } - - private int _maxAgentIterations; - public int MaxAgentIterations - { - get => _maxAgentIterations; - set { _maxAgentIterations = Math.Clamp(value, 1, 100); OnPropertyChanged(); } - } - - private int _maxRetryOnError; - public int MaxRetryOnError - { - get => _maxRetryOnError; - set { _maxRetryOnError = Math.Clamp(value, 0, 10); OnPropertyChanged(); } - } - - private string _agentLogLevel; - public string AgentLogLevel - { - get => _agentLogLevel; - set { _agentLogLevel = value; OnPropertyChanged(); } - } - - private string _agentDecisionLevel = "detailed"; - public string AgentDecisionLevel - { - get => _agentDecisionLevel; - set { _agentDecisionLevel = value; OnPropertyChanged(); } - } - - private string _planMode = "off"; - public string PlanMode - { - get => _planMode; - set { _planMode = value; OnPropertyChanged(); } - } - - private bool _enableMultiPassDocument; - public bool EnableMultiPassDocument - { - get => _enableMultiPassDocument; - set { _enableMultiPassDocument = value; OnPropertyChanged(); } - } - - private bool _enableCoworkVerification; - public bool EnableCoworkVerification - { - get => _enableCoworkVerification; - set { _enableCoworkVerification = value; OnPropertyChanged(); } - } - - private bool _enableFilePathHighlight = true; - public bool EnableFilePathHighlight - { - get => _enableFilePathHighlight; - set { _enableFilePathHighlight = value; OnPropertyChanged(); } - } - - private string _folderDataUsage; - public string FolderDataUsage - { - get => _folderDataUsage; - set { _folderDataUsage = value; OnPropertyChanged(); } - } - - // ── 모델 폴백 + 보안 + MCP ── - private bool _enableAuditLog; - public bool EnableAuditLog - { - get => _enableAuditLog; - set { _enableAuditLog = value; OnPropertyChanged(); } - } - - private bool _enableAgentMemory; - public bool EnableAgentMemory - { - get => _enableAgentMemory; - set { _enableAgentMemory = value; OnPropertyChanged(); } - } - - private bool _enableProjectRules = true; - public bool EnableProjectRules - { - get => _enableProjectRules; - set { _enableProjectRules = value; OnPropertyChanged(); } - } - - private int _maxMemoryEntries; - public int MaxMemoryEntries - { - get => _maxMemoryEntries; - set { _maxMemoryEntries = value; OnPropertyChanged(); } - } - - // ── 이미지 입력 (멀티모달) ── - private bool _enableImageInput = true; - public bool EnableImageInput - { - get => _enableImageInput; - set { _enableImageInput = value; OnPropertyChanged(); } - } - - private int _maxImageSizeKb = 5120; - public int MaxImageSizeKb - { - get => _maxImageSizeKb; - set { _maxImageSizeKb = value; OnPropertyChanged(); } - } - - // ── 자동 모델 라우팅 ── - private bool _enableAutoRouter; - public bool EnableAutoRouter - { - get => _enableAutoRouter; - set { _enableAutoRouter = value; OnPropertyChanged(); } - } - - private double _autoRouterConfidence = 0.7; - public double AutoRouterConfidence - { - get => _autoRouterConfidence; - set { _autoRouterConfidence = value; OnPropertyChanged(); } - } - - // ── 에이전트 훅 시스템 ── - private bool _enableToolHooks = true; - public bool EnableToolHooks - { - get => _enableToolHooks; - set { _enableToolHooks = value; OnPropertyChanged(); } - } - - private int _toolHookTimeoutMs = 10000; - public int ToolHookTimeoutMs - { - get => _toolHookTimeoutMs; - set { _toolHookTimeoutMs = value; OnPropertyChanged(); } - } - - // ── 스킬 시스템 ── - private bool _enableSkillSystem = true; - public bool EnableSkillSystem - { - get => _enableSkillSystem; - set { _enableSkillSystem = value; OnPropertyChanged(); } - } - - private string _skillsFolderPath = ""; - public string SkillsFolderPath - { - get => _skillsFolderPath; - set { _skillsFolderPath = value; OnPropertyChanged(); } - } - - private int _slashPopupPageSize = 6; - public int SlashPopupPageSize - { - get => _slashPopupPageSize; - set { _slashPopupPageSize = Math.Clamp(value, 3, 10); OnPropertyChanged(); } - } - - // ── 드래그&드롭 AI ── - private bool _enableDragDropAiActions = true; - public bool EnableDragDropAiActions - { - get => _enableDragDropAiActions; - set { _enableDragDropAiActions = value; OnPropertyChanged(); } - } - - private bool _dragDropAutoSend; - public bool DragDropAutoSend - { - get => _dragDropAutoSend; - set { _dragDropAutoSend = value; OnPropertyChanged(); } - } - - // ── 코드 리뷰 ── - private bool _enableCodeReview = true; - public bool EnableCodeReview - { - get => _enableCodeReview; - set { _enableCodeReview = value; OnPropertyChanged(); } - } - - // ── 시각 효과 + 알림 + 개발자 모드 (공통) ── - private bool _enableChatRainbowGlow; - public bool EnableChatRainbowGlow - { - get => _enableChatRainbowGlow; - set { _enableChatRainbowGlow = value; OnPropertyChanged(); } - } - - private bool _notifyOnComplete; - public bool NotifyOnComplete - { - get => _notifyOnComplete; - set { _notifyOnComplete = value; OnPropertyChanged(); } - } - - private bool _showTips; - public bool ShowTips - { - get => _showTips; - set { _showTips = value; OnPropertyChanged(); } - } - - private bool _devMode; - public bool DevMode - { - get => _devMode; - set { _devMode = value; OnPropertyChanged(); } - } - - private bool _devModeStepApproval; - public bool DevModeStepApproval - { - get => _devModeStepApproval; - set { _devModeStepApproval = value; OnPropertyChanged(); } - } - - private bool _workflowVisualizer; - public bool WorkflowVisualizer - { - get => _workflowVisualizer; - set { _workflowVisualizer = value; OnPropertyChanged(); } - } - - private bool _freeTierMode; - public bool FreeTierMode - { - get => _freeTierMode; - set { _freeTierMode = value; OnPropertyChanged(); } - } - - private int _freeTierDelaySeconds = 4; - public int FreeTierDelaySeconds - { - get => _freeTierDelaySeconds; - set { _freeTierDelaySeconds = value; OnPropertyChanged(); } - } - - private bool _showTotalCallStats; - public bool ShowTotalCallStats - { - get => _showTotalCallStats; - set { _showTotalCallStats = value; OnPropertyChanged(); } - } - - private string _defaultMood = "modern"; - public string DefaultMood - { - get => _defaultMood; - set { _defaultMood = value; OnPropertyChanged(); } - } - - // 차단 경로/확장자 (읽기 전용 UI) - public ObservableCollection BlockedPaths { get; } = new(); - public ObservableCollection BlockedExtensions { get; } = new(); - - public string Hotkey - { - get => _hotkey; - set { _hotkey = value; OnPropertyChanged(); } - } - - public int MaxResults - { - get => _maxResults; - set { _maxResults = value; OnPropertyChanged(); } - } - - public double Opacity - { - get => _opacity; - set { _opacity = value; OnPropertyChanged(); OnPropertyChanged(nameof(OpacityPercent)); } - } - - public int OpacityPercent => (int)Math.Round(_opacity * 100); - - public string SelectedThemeKey - { - get => _selectedThemeKey; - set - { - _selectedThemeKey = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(IsCustomTheme)); - foreach (var card in ThemeCards) - card.IsSelected = card.Key == value; - } - } - - public bool IsCustomTheme => _selectedThemeKey == "custom"; - - public string LauncherPosition - { - get => _launcherPosition; - set { _launcherPosition = value; OnPropertyChanged(); } - } - - public string WebSearchEngine - { - get => _webSearchEngine; - set { _webSearchEngine = value; OnPropertyChanged(); } - } - - public bool SnippetAutoExpand - { - get => _snippetAutoExpand; - set { _snippetAutoExpand = value; OnPropertyChanged(); } - } - - public string Language - { - get => _language; - set { _language = value; OnPropertyChanged(); } - } - - public string IndexSpeed - { - get => _indexSpeed; - set { _indexSpeed = value; OnPropertyChanged(); OnPropertyChanged(nameof(IndexSpeedHint)); } - } - - public string IndexSpeedHint => _indexSpeed switch - { - "fast" => "CPU 사용률이 높아질 수 있습니다. 고성능 PC에 권장합니다.", - "slow" => "인덱싱이 오래 걸리지만 PC 성능에 영향을 주지 않습니다.", - _ => "일반적인 PC에 적합한 균형 설정입니다.", - }; - // ─── 기능 토글 속성 ─────────────────────────────────────────────────── public bool ShowNumberBadges diff --git a/src/AxCopilot/Views/ChatWindow.BottomBar.cs b/src/AxCopilot/Views/ChatWindow.BottomBar.cs new file mode 100644 index 0000000..72dfbc0 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.BottomBar.cs @@ -0,0 +1,345 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 하단 바 ────────────────────────────────────────────────────────── + + /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). + private string _selectedMood = null!; // Loaded 이벤트에서 초기화 + private string _selectedLanguage = "auto"; // Code 탭 개발 언어 + private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 + + /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼). + private void BuildBottomBar() + { + MoodIconPanel.Children.Clear(); + + var secondaryText = ThemeResourceHelper.Secondary(this); + + // ── 포맷 버튼 ── + var currentFormat = Llm.DefaultOutputFormat ?? "auto"; + var formatLabel = GetFormatLabel(currentFormat); + var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6"); + formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); }; + // Name 등록 (Popup PlacementTarget용) + try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(formatBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // ── 디자인 버튼 (소극 스타일) ── + var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood); + var moodLabel = currentMood?.Label ?? "모던"; + var moodIcon = currentMood?.Icon ?? "🔷"; + var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택"); + moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); }; + try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(moodBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // ── 파일 탐색기 토글 버튼 ── + var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); + fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; + MoodIconPanel.Children.Add(fileBrowserBtn); + + // ── 실행 이력 상세도 버튼 ── + AppendLogLevelButton(); + + // 구분선 표시 + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + } + + /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글. + private void BuildCodeBottomBar() + { + MoodIconPanel.Children.Clear(); + + var secondaryText = ThemeResourceHelper.Secondary(this); + + // 개발 언어 선택 버튼 + var langLabel = _selectedLanguage switch + { + "python" => "🐍 Python", + "java" => "☕ Java", + "csharp" => "🔷 C#", + "cpp" => "⚙ C++", + "javascript" => "🌐 JavaScript", + _ => "🔧 자동 감지", + }; + var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택"); + langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); }; + try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(langBtn); + + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + // 파일 탐색기 토글 + var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); + fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; + MoodIconPanel.Children.Add(fileBrowserBtn); + + // ── 실행 이력 상세도 버튼 ── + AppendLogLevelButton(); + + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + } + + /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. + private void AppendLogLevelButton() + { + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + var currentLevel = Llm.AgentLogLevel ?? "simple"; + var levelLabel = currentLevel switch + { + "debug" => "디버그", + "detailed" => "상세", + _ => "간략", + }; + var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669"); + logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); }; + try { RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } } + MoodIconPanel.Children.Add(logBtn); + } + + /// 실행 이력 상세도 팝업 메뉴를 표시합니다. + private void ShowLogLevelMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = ThemeResourceHelper.Primary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + var levels = new (string Key, string Label, string Desc)[] + { + ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), + ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), + ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), + }; + + var current = Llm.AgentLogLevel ?? "simple"; + + foreach (var (key, label, desc) in levels) + { + var isActive = current == key; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 13, + Foreground = isActive ? accentBrush : primaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = desc, + FontSize = 10, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + var item = new Border + { + Child = sp, + Padding = new Thickness(12, 8, 12, 8), + CornerRadius = new CornerRadius(6), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + }; + var hoverBg = ThemeResourceHelper.HoverBg(this); + item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; + item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; + item.MouseLeftButtonUp += (_, _) => + { + Llm.AgentLogLevel = key; + _settings.Save(); + FormatMenuPopup.IsOpen = false; + if (_activeTab == "Cowork") BuildBottomBar(); + else if (_activeTab == "Code") BuildCodeBottomBar(); + }; + FormatMenuItems.Children.Add(item); + } + + try + { + var target = FindName("BtnLogLevelMenu") as UIElement; + if (target != null) FormatMenuPopup.PlacementTarget = target; + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + FormatMenuPopup.IsOpen = true; + } + + private void ShowLanguageMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = ThemeResourceHelper.Primary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + + var languages = new (string Key, string Label, string Icon)[] + { + ("auto", "자동 감지", "🔧"), + ("python", "Python", "🐍"), + ("java", "Java", "☕"), + ("csharp", "C# (.NET)", "🔷"), + ("cpp", "C/C++", "⚙"), + ("javascript", "JavaScript / Vue", "🌐"), + }; + + foreach (var (key, label, icon) in languages) + { + var isActive = _selectedLanguage == key; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) }); + sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal }); + + var itemBorder = new Border + { + Child = sp, Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, + Padding = new Thickness(8, 7, 12, 7), + }; + ApplyMenuItemHover(itemBorder); + + var capturedKey = key; + itemBorder.MouseLeftButtonUp += (_, _) => + { + FormatMenuPopup.IsOpen = false; + _selectedLanguage = capturedKey; + BuildCodeBottomBar(); + }; + FormatMenuItems.Children.Add(itemBorder); + } + + if (FindName("BtnLangMenu") is UIElement langTarget) + FormatMenuPopup.PlacementTarget = langTarget; + FormatMenuPopup.IsOpen = true; + } + + /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일) + private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null) + { + var secondaryText = ThemeResourceHelper.Secondary(this); + var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + if (mdlIcon != null) + { + sp.Children.Add(new TextBlock + { + Text = mdlIcon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, + Foreground = iconColor, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + } + + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + return new Border + { + Child = sp, + Background = Brushes.Transparent, + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + ToolTip = tooltip, + }; + } + + private static string GetFormatLabel(string key) => key switch + { + "xlsx" => "Excel", + "html" => "HTML 보고서", + "docx" => "Word", + "md" => "Markdown", + "csv" => "CSV", + _ => "AI 자동", + }; + + /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다. + private (string Name, string Symbol, string Color) GetAgentIdentity() + { + string? category = null; + lock (_convLock) + { + category = _currentConversation?.Category; + } + + return category switch + { + // Cowork 프리셋 카테고리 + "보고서" => ("보고서 에이전트", "◆", "#3B82F6"), + "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"), + "문서" => ("문서 작성 에이전트", "◆", "#6366F1"), + "논문" => ("논문 분석 에이전트", "◆", "#6366F1"), + "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"), + "자동화" => ("자동화 에이전트", "◆", "#EF4444"), + // Code 프리셋 카테고리 + "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"), + "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"), + "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"), + "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"), + "테스트" => ("테스트 에이전트", "◆", "#F59E0B"), + // Chat 카테고리 + "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"), + "시스템" => ("시스템 에이전트", "◆", "#64748B"), + "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"), + "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"), + "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"), + "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"), + "제조기술" => ("제조기술 에이전트", "◆", "#F97316"), + "재무" => ("재무 분석 에이전트", "◆", "#6366F1"), + _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"), + _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"), + _ => ("AX 에이전트", "◆", "#4B5EFC"), + }; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ConversationList.cs b/src/AxCopilot/Views/ChatWindow.ConversationList.cs index 4c840e9..4ae25c3 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationList.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationList.cs @@ -1,6 +1,5 @@ using System.Windows; using System.Windows.Controls; -using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; @@ -379,347 +378,6 @@ public partial class ChatWindow ConversationPanel.Children.Add(border); } - // ─── 대화 제목 인라인 편집 ──────────────────────────────────────────── - - private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor) - { - try - { - // titleTb가 이미 부모에서 분리된 경우(편집 중) 무시 - var parent = titleTb.Parent as StackPanel; - if (parent == null) return; - - var idx = parent.Children.IndexOf(titleTb); - if (idx < 0) return; - - var editBox = new TextBox - { - Text = titleTb.Text, - FontSize = 12.5, - Foreground = titleColor, - Background = Brushes.Transparent, - BorderBrush = ThemeResourceHelper.Accent(this), - BorderThickness = new Thickness(0, 0, 0, 1), - CaretBrush = titleColor, - Padding = new Thickness(0), - Margin = new Thickness(0), - }; - - // 안전하게 자식 교체: 먼저 제거 후 삽입 - parent.Children.RemoveAt(idx); - parent.Children.Insert(idx, editBox); - - var committed = false; - void CommitEdit() - { - if (committed) return; - committed = true; - - var newTitle = editBox.Text.Trim(); - if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text; - - titleTb.Text = newTitle; - // editBox가 아직 parent에 있는지 확인 후 교체 - try - { - var currentIdx = parent.Children.IndexOf(editBox); - if (currentIdx >= 0) - { - parent.Children.RemoveAt(currentIdx); - parent.Children.Insert(currentIdx, titleTb); - } - } - catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } - - var conv = _storage.Load(conversationId); - if (conv != null) - { - conv.Title = newTitle; - _storage.Save(conv); - lock (_convLock) - { - if (_currentConversation?.Id == conversationId) - _currentConversation.Title = newTitle; - } - UpdateChatTitle(); - } - } - - void CancelEdit() - { - if (committed) return; - committed = true; - try - { - var currentIdx = parent.Children.IndexOf(editBox); - if (currentIdx >= 0) - { - parent.Children.RemoveAt(currentIdx); - parent.Children.Insert(currentIdx, titleTb); - } - } - catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } - } - - editBox.KeyDown += (_, ke) => - { - if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); } - if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); } - }; - editBox.LostFocus += (_, _) => CommitEdit(); - - editBox.Focus(); - editBox.SelectAll(); - } - catch (Exception ex) - { - LogService.Error($"제목 편집 오류: {ex.Message}"); - } - } - - // ─── 카테고리 변경 팝업 ────────────────────────────────────────────── - - private void ShowConversationMenu(string conversationId) - { - var conv = _storage.Load(conversationId); - var isPinned = conv?.Pinned ?? false; - - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var hoverBg = ThemeResourceHelper.HoverBg(this); - - var (popup, stack) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200); - - // 메뉴 항목 헬퍼 — PopupMenuHelper.MenuItem 래핑 (아이콘 색상 개별 지정) - Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick) - => PopupMenuHelper.MenuItem(text, primaryText, hoverBg, - () => { popup.IsOpen = false; onClick(); }, - icon: icon, iconColor: iconColor, fontSize: 12.5); - - Border CreateSeparator() => PopupMenuHelper.Separator(); - - // 고정/해제 - stack.Children.Add(CreateMenuItem( - isPinned ? "\uE77A" : "\uE718", - isPinned ? "고정 해제" : "상단 고정", - ThemeResourceHelper.Accent(this), - () => - { - var c = _storage.Load(conversationId); - if (c != null) - { - c.Pinned = !c.Pinned; - _storage.Save(c); - lock (_convLock) { if (_currentConversation?.Id == conversationId) _currentConversation.Pinned = c.Pinned; } - RefreshConversationList(); - } - })); - - // 이름 변경 - stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () => - { - // 대화 목록에서 해당 항목 찾아서 편집 모드 진입 - foreach (UIElement child in ConversationPanel.Children) - { - if (child is Border b && b.Child is Grid g) - { - foreach (UIElement gc in g.Children) - { - if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) - { - // title과 매칭 - if (conv != null && tb.Text == conv.Title) - { - var titleColor = ThemeResourceHelper.Primary(this); - EnterTitleEditMode(tb, conversationId, titleColor); - return; - } - } - } - } - } - })); - - // Cowork/Code 탭: 작업 유형 읽기 전용 표시 - if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null) - { - var catKey = conv.Category ?? ChatCategory.General; - // ChatCategory 또는 프리셋에서 아이콘/라벨 검색 - string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280"; - var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey); - if (chatCat != default && chatCat.Key != ChatCategory.General) - { - catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color; - } - else - { - var preset = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets) - .FirstOrDefault(p => p.Category == catKey); - if (preset != null) - { - catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color; - } - } - - stack.Children.Add(CreateSeparator()); - var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) }; - try - { - var catBrush = ThemeResourceHelper.HexBrush(catColor); - infoSp.Children.Add(new TextBlock - { - Text = catSymbol, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = catBrush, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), - }); - infoSp.Children.Add(new TextBlock - { - Text = catLabel, FontSize = 12, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - } - catch (Exception) - { - infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText }); - } - stack.Children.Add(infoSp); - } - - // Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요) - var showCategorySection = _activeTab == "Chat"; - - if (showCategorySection) - { - stack.Children.Add(CreateSeparator()); - - // 분류 헤더 - stack.Children.Add(new TextBlock - { - Text = "분류 변경", - FontSize = 10.5, - Foreground = secondaryText, - Margin = new Thickness(10, 4, 0, 4), - FontWeight = FontWeights.SemiBold, - }); - - var currentCategory = conv?.Category ?? ChatCategory.General; - var accentBrush = ThemeResourceHelper.Accent(this); - - foreach (var (key, label, symbol, color) in ChatCategory.All) - { - var capturedKey = key; - var isCurrentCat = capturedKey == currentCategory; - - // 카테고리 항목 (체크 표시 포함) - var catItem = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 7, 10, 7), - Margin = new Thickness(0, 1, 0, 1), - Cursor = Cursors.Hand, - }; - var catGrid = new Grid(); - catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); - catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); - - var catIcon = new TextBlock - { - Text = symbol, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(catIcon, 0); - catGrid.Children.Add(catIcon); - - var catText = new TextBlock - { - Text = label, FontSize = 12.5, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal, - }; - Grid.SetColumn(catText, 1); - catGrid.Children.Add(catText); - - if (isCurrentCat) - { - var check = CreateSimpleCheck(accentBrush, 14); - Grid.SetColumn(check, 2); - catGrid.Children.Add(check); - } - - catItem.Child = catGrid; - catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; - catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - catItem.MouseLeftButtonUp += (_, _) => - { - popup.IsOpen = false; - var c = _storage.Load(conversationId); - if (c != null) - { - c.Category = capturedKey; - var preset = Services.PresetService.GetByCategory(capturedKey); - if (preset != null) - c.SystemCommand = preset.SystemPrompt; - _storage.Save(c); - lock (_convLock) - { - if (_currentConversation?.Id == conversationId) - { - _currentConversation.Category = capturedKey; - if (preset != null) - _currentConversation.SystemCommand = preset.SystemPrompt; - } - } - // 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신 - bool isCurrent; - lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; } - if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder)) - { - _promptCardPlaceholder = preset.Placeholder; - UpdateWatermarkVisibility(); - if (string.IsNullOrEmpty(InputBox.Text)) - { - InputWatermark.Text = preset.Placeholder; - InputWatermark.Visibility = Visibility.Visible; - } - } - else if (isCurrent) - { - ClearPromptCardPlaceholder(); - } - RefreshConversationList(); - } - }; - stack.Children.Add(catItem); - } - } // end showCategorySection - - stack.Children.Add(CreateSeparator()); - - // 삭제 - stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => - { - var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", - MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result != MessageBoxResult.Yes) return; - _storage.Delete(conversationId); - lock (_convLock) - { - if (_currentConversation?.Id == conversationId) - { - _currentConversation = null; - MessagePanel.Children.Clear(); - EmptyState.Visibility = Visibility.Visible; - UpdateChatTitle(); - } - } - RefreshConversationList(); - })); - - popup.IsOpen = true; - } - // ─── 검색 ──────────────────────────────────────────────────────────── private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) diff --git a/src/AxCopilot/Views/ChatWindow.ConversationMenu.cs b/src/AxCopilot/Views/ChatWindow.ConversationMenu.cs new file mode 100644 index 0000000..4b91d6c --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ConversationMenu.cs @@ -0,0 +1,255 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 카테고리 변경 팝업 ────────────────────────────────────────────── + + private void ShowConversationMenu(string conversationId) + { + var conv = _storage.Load(conversationId); + var isPinned = conv?.Pinned ?? false; + + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var hoverBg = ThemeResourceHelper.HoverBg(this); + + var (popup, stack) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200); + + // 메뉴 항목 헬퍼 — PopupMenuHelper.MenuItem 래핑 (아이콘 색상 개별 지정) + Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick) + => PopupMenuHelper.MenuItem(text, primaryText, hoverBg, + () => { popup.IsOpen = false; onClick(); }, + icon: icon, iconColor: iconColor, fontSize: 12.5); + + Border CreateSeparator() => PopupMenuHelper.Separator(); + + // 고정/해제 + stack.Children.Add(CreateMenuItem( + isPinned ? "\uE77A" : "\uE718", + isPinned ? "고정 해제" : "상단 고정", + ThemeResourceHelper.Accent(this), + () => + { + var c = _storage.Load(conversationId); + if (c != null) + { + c.Pinned = !c.Pinned; + _storage.Save(c); + lock (_convLock) { if (_currentConversation?.Id == conversationId) _currentConversation.Pinned = c.Pinned; } + RefreshConversationList(); + } + })); + + // 이름 변경 + stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () => + { + // 대화 목록에서 해당 항목 찾아서 편집 모드 진입 + foreach (UIElement child in ConversationPanel.Children) + { + if (child is Border b && b.Child is Grid g) + { + foreach (UIElement gc in g.Children) + { + if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) + { + // title과 매칭 + if (conv != null && tb.Text == conv.Title) + { + var titleColor = ThemeResourceHelper.Primary(this); + EnterTitleEditMode(tb, conversationId, titleColor); + return; + } + } + } + } + } + })); + + // Cowork/Code 탭: 작업 유형 읽기 전용 표시 + if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null) + { + var catKey = conv.Category ?? ChatCategory.General; + // ChatCategory 또는 프리셋에서 아이콘/라벨 검색 + string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280"; + var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey); + if (chatCat != default && chatCat.Key != ChatCategory.General) + { + catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color; + } + else + { + var preset = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets) + .FirstOrDefault(p => p.Category == catKey); + if (preset != null) + { + catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color; + } + } + + stack.Children.Add(CreateSeparator()); + var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) }; + try + { + var catBrush = ThemeResourceHelper.HexBrush(catColor); + infoSp.Children.Add(new TextBlock + { + Text = catSymbol, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = catBrush, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + infoSp.Children.Add(new TextBlock + { + Text = catLabel, FontSize = 12, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + } + catch (Exception) + { + infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText }); + } + stack.Children.Add(infoSp); + } + + // Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요) + var showCategorySection = _activeTab == "Chat"; + + if (showCategorySection) + { + stack.Children.Add(CreateSeparator()); + + // 분류 헤더 + stack.Children.Add(new TextBlock + { + Text = "분류 변경", + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(10, 4, 0, 4), + FontWeight = FontWeights.SemiBold, + }); + + var currentCategory = conv?.Category ?? ChatCategory.General; + var accentBrush = ThemeResourceHelper.Accent(this); + + foreach (var (key, label, symbol, color) in ChatCategory.All) + { + var capturedKey = key; + var isCurrentCat = capturedKey == currentCategory; + + // 카테고리 항목 (체크 표시 포함) + var catItem = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + }; + var catGrid = new Grid(); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); + + var catIcon = new TextBlock + { + Text = symbol, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(catIcon, 0); + catGrid.Children.Add(catIcon); + + var catText = new TextBlock + { + Text = label, FontSize = 12.5, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal, + }; + Grid.SetColumn(catText, 1); + catGrid.Children.Add(catText); + + if (isCurrentCat) + { + var check = CreateSimpleCheck(accentBrush, 14); + Grid.SetColumn(check, 2); + catGrid.Children.Add(check); + } + + catItem.Child = catGrid; + catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + catItem.MouseLeftButtonUp += (_, _) => + { + popup.IsOpen = false; + var c = _storage.Load(conversationId); + if (c != null) + { + c.Category = capturedKey; + var preset = Services.PresetService.GetByCategory(capturedKey); + if (preset != null) + c.SystemCommand = preset.SystemPrompt; + _storage.Save(c); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + { + _currentConversation.Category = capturedKey; + if (preset != null) + _currentConversation.SystemCommand = preset.SystemPrompt; + } + } + // 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신 + bool isCurrent; + lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; } + if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder)) + { + _promptCardPlaceholder = preset.Placeholder; + UpdateWatermarkVisibility(); + if (string.IsNullOrEmpty(InputBox.Text)) + { + InputWatermark.Text = preset.Placeholder; + InputWatermark.Visibility = Visibility.Visible; + } + } + else if (isCurrent) + { + ClearPromptCardPlaceholder(); + } + RefreshConversationList(); + } + }; + stack.Children.Add(catItem); + } + } // end showCategorySection + + stack.Children.Add(CreateSeparator()); + + // 삭제 + stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => + { + var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", + MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) return; + _storage.Delete(conversationId); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + { + _currentConversation = null; + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + UpdateChatTitle(); + } + } + RefreshConversationList(); + })); + + popup.IsOpen = true; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs b/src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs new file mode 100644 index 0000000..7fcbf2d --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs @@ -0,0 +1,108 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 대화 제목 인라인 편집 ──────────────────────────────────────────── + + private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor) + { + try + { + // titleTb가 이미 부모에서 분리된 경우(편집 중) 무시 + var parent = titleTb.Parent as StackPanel; + if (parent == null) return; + + var idx = parent.Children.IndexOf(titleTb); + if (idx < 0) return; + + var editBox = new TextBox + { + Text = titleTb.Text, + FontSize = 12.5, + Foreground = titleColor, + Background = Brushes.Transparent, + BorderBrush = ThemeResourceHelper.Accent(this), + BorderThickness = new Thickness(0, 0, 0, 1), + CaretBrush = titleColor, + Padding = new Thickness(0), + Margin = new Thickness(0), + }; + + // 안전하게 자식 교체: 먼저 제거 후 삽입 + parent.Children.RemoveAt(idx); + parent.Children.Insert(idx, editBox); + + var committed = false; + void CommitEdit() + { + if (committed) return; + committed = true; + + var newTitle = editBox.Text.Trim(); + if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text; + + titleTb.Text = newTitle; + // editBox가 아직 parent에 있는지 확인 후 교체 + try + { + var currentIdx = parent.Children.IndexOf(editBox); + if (currentIdx >= 0) + { + parent.Children.RemoveAt(currentIdx); + parent.Children.Insert(currentIdx, titleTb); + } + } + catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } + + var conv = _storage.Load(conversationId); + if (conv != null) + { + conv.Title = newTitle; + _storage.Save(conv); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + _currentConversation.Title = newTitle; + } + UpdateChatTitle(); + } + } + + void CancelEdit() + { + if (committed) return; + committed = true; + try + { + var currentIdx = parent.Children.IndexOf(editBox); + if (currentIdx >= 0) + { + parent.Children.RemoveAt(currentIdx); + parent.Children.Insert(currentIdx, titleTb); + } + } + catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } + } + + editBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); } + if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); } + }; + editBox.LostFocus += (_, _) => CommitEdit(); + + editBox.Focus(); + editBox.SelectAll(); + } + catch (Exception ex) + { + LogService.Error($"제목 편집 오류: {ex.Message}"); + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.CustomPresets.cs b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs index 860ad2e..6f31938 100644 --- a/src/AxCopilot/Views/ChatWindow.CustomPresets.cs +++ b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs @@ -200,779 +200,4 @@ public partial class ChatWindow if (_activeTab == "Cowork") BuildBottomBar(); } - - - - /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). - private string _selectedMood = null!; // Loaded 이벤트에서 초기화 - private string _selectedLanguage = "auto"; // Code 탭 개발 언어 - private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 - - /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼). - private void BuildBottomBar() - { - MoodIconPanel.Children.Clear(); - - var secondaryText = ThemeResourceHelper.Secondary(this); - - // ── 포맷 버튼 ── - var currentFormat = Llm.DefaultOutputFormat ?? "auto"; - var formatLabel = GetFormatLabel(currentFormat); - var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6"); - formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); }; - // Name 등록 (Popup PlacementTarget용) - try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(formatBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // ── 디자인 버튼 (소극 스타일) ── - var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood); - var moodLabel = currentMood?.Label ?? "모던"; - var moodIcon = currentMood?.Icon ?? "🔷"; - var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택"); - moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); }; - try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(moodBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // ── 파일 탐색기 토글 버튼 ── - var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); - fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; - MoodIconPanel.Children.Add(fileBrowserBtn); - - // ── 실행 이력 상세도 버튼 ── - AppendLogLevelButton(); - - // 구분선 표시 - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; - } - - /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글. - private void BuildCodeBottomBar() - { - MoodIconPanel.Children.Clear(); - - var secondaryText = ThemeResourceHelper.Secondary(this); - - // 개발 언어 선택 버튼 - var langLabel = _selectedLanguage switch - { - "python" => "🐍 Python", - "java" => "☕ Java", - "csharp" => "🔷 C#", - "cpp" => "⚙ C++", - "javascript" => "🌐 JavaScript", - _ => "🔧 자동 감지", - }; - var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택"); - langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); }; - try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(langBtn); - - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - // 파일 탐색기 토글 - var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); - fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; - MoodIconPanel.Children.Add(fileBrowserBtn); - - // ── 실행 이력 상세도 버튼 ── - AppendLogLevelButton(); - - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; - } - - /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. - private void AppendLogLevelButton() - { - // 구분선 - MoodIconPanel.Children.Add(new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - - var currentLevel = Llm.AgentLogLevel ?? "simple"; - var levelLabel = currentLevel switch - { - "debug" => "디버그", - "detailed" => "상세", - _ => "간략", - }; - var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669"); - logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); }; - try { RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } } - MoodIconPanel.Children.Add(logBtn); - } - - /// 실행 이력 상세도 팝업 메뉴를 표시합니다. - private void ShowLogLevelMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = ThemeResourceHelper.Primary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - var levels = new (string Key, string Label, string Desc)[] - { - ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), - ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), - ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), - }; - - var current = Llm.AgentLogLevel ?? "simple"; - - foreach (var (key, label, desc) in levels) - { - var isActive = current == key; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 13, - Foreground = isActive ? accentBrush : primaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = desc, - FontSize = 10, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - var item = new Border - { - Child = sp, - Padding = new Thickness(12, 8, 12, 8), - CornerRadius = new CornerRadius(6), - Background = Brushes.Transparent, - Cursor = Cursors.Hand, - }; - var hoverBg = ThemeResourceHelper.HoverBg(this); - item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; - item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; - item.MouseLeftButtonUp += (_, _) => - { - Llm.AgentLogLevel = key; - _settings.Save(); - FormatMenuPopup.IsOpen = false; - if (_activeTab == "Cowork") BuildBottomBar(); - else if (_activeTab == "Code") BuildCodeBottomBar(); - }; - FormatMenuItems.Children.Add(item); - } - - try - { - var target = FindName("BtnLogLevelMenu") as UIElement; - if (target != null) FormatMenuPopup.PlacementTarget = target; - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - FormatMenuPopup.IsOpen = true; - } - - private void ShowLanguageMenu() - { - FormatMenuItems.Children.Clear(); - var primaryText = ThemeResourceHelper.Primary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - - var languages = new (string Key, string Label, string Icon)[] - { - ("auto", "자동 감지", "🔧"), - ("python", "Python", "🐍"), - ("java", "Java", "☕"), - ("csharp", "C# (.NET)", "🔷"), - ("cpp", "C/C++", "⚙"), - ("javascript", "JavaScript / Vue", "🌐"), - }; - - foreach (var (key, label, icon) in languages) - { - var isActive = _selectedLanguage == key; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) }); - sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal }); - - var itemBorder = new Border - { - Child = sp, Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, - Padding = new Thickness(8, 7, 12, 7), - }; - ApplyMenuItemHover(itemBorder); - - var capturedKey = key; - itemBorder.MouseLeftButtonUp += (_, _) => - { - FormatMenuPopup.IsOpen = false; - _selectedLanguage = capturedKey; - BuildCodeBottomBar(); - }; - FormatMenuItems.Children.Add(itemBorder); - } - - if (FindName("BtnLangMenu") is UIElement langTarget) - FormatMenuPopup.PlacementTarget = langTarget; - FormatMenuPopup.IsOpen = true; - } - - /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일) - private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null) - { - var secondaryText = ThemeResourceHelper.Secondary(this); - var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - - if (mdlIcon != null) - { - sp.Children.Add(new TextBlock - { - Text = mdlIcon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, - Foreground = iconColor, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - } - - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 12, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - return new Border - { - Child = sp, - Background = Brushes.Transparent, - Padding = new Thickness(6, 4, 6, 4), - Cursor = Cursors.Hand, - ToolTip = tooltip, - }; - } - - - private static string GetFormatLabel(string key) => key switch - { - "xlsx" => "Excel", - "html" => "HTML 보고서", - "docx" => "Word", - "md" => "Markdown", - "csv" => "CSV", - _ => "AI 자동", - }; - - /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다. - private (string Name, string Symbol, string Color) GetAgentIdentity() - { - string? category = null; - lock (_convLock) - { - category = _currentConversation?.Category; - } - - return category switch - { - // Cowork 프리셋 카테고리 - "보고서" => ("보고서 에이전트", "◆", "#3B82F6"), - "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"), - "문서" => ("문서 작성 에이전트", "◆", "#6366F1"), - "논문" => ("논문 분석 에이전트", "◆", "#6366F1"), - "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"), - "자동화" => ("자동화 에이전트", "◆", "#EF4444"), - // Code 프리셋 카테고리 - "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"), - "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"), - "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"), - "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"), - "테스트" => ("테스트 에이전트", "◆", "#F59E0B"), - // Chat 카테고리 - "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"), - "시스템" => ("시스템 에이전트", "◆", "#64748B"), - "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"), - "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"), - "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"), - "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"), - "제조기술" => ("제조기술 에이전트", "◆", "#F97316"), - "재무" => ("재무 분석 에이전트", "◆", "#6366F1"), - _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"), - _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"), - _ => ("AX 에이전트", "◆", "#4B5EFC"), - }; - } - - /// 포맷 선택 팝업 메뉴를 표시합니다. - private void ShowFormatMenu() - { - FormatMenuItems.Children.Clear(); - - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var currentFormat = Llm.DefaultOutputFormat ?? "auto"; - - var formats = new (string Key, string Label, string Icon, string Color)[] - { - ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"), - ("xlsx", "Excel", "\uE9F9", "#217346"), - ("html", "HTML 보고서", "\uE12B", "#E44D26"), - ("docx", "Word", "\uE8A5", "#2B579A"), - ("md", "Markdown", "\uE943", "#6B7280"), - ("csv", "CSV", "\uE9D9", "#10B981"), - }; - - foreach (var (key, label, icon, color) in formats) - { - var isActive = key == currentFormat; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); - - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, - Foreground = BrushFromHex(color), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 13, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - - var itemBorder = new Border - { - Child = sp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(8, 7, 12, 7), - }; - ApplyMenuItemHover(itemBorder); - - var capturedKey = key; - itemBorder.MouseLeftButtonUp += (_, _) => - { - FormatMenuPopup.IsOpen = false; - Llm.DefaultOutputFormat = capturedKey; - _settings.Save(); - BuildBottomBar(); - }; - - FormatMenuItems.Children.Add(itemBorder); - } - - // PlacementTarget을 동적 등록된 버튼으로 설정 - if (FindName("BtnFormatMenu") is UIElement formatTarget) - FormatMenuPopup.PlacementTarget = formatTarget; - FormatMenuPopup.IsOpen = true; - } - - /// 디자인 무드 선택 팝업 메뉴를 표시합니다. - private void ShowMoodMenu() - { - MoodMenuItems.Children.Clear(); - - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var borderBrush = ThemeResourceHelper.Border(this); - - // 2열 갤러리 그리드 - var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; - - foreach (var mood in TemplateService.AllMoods) - { - var isActive = _selectedMood == mood.Key; - var isCustom = Llm.CustomMoods.Any(cm => cm.Key == mood.Key); - var colors = TemplateService.GetMoodColors(mood.Key); - - // 미니 프리뷰 카드 - var previewCard = new Border - { - Width = 160, Height = 80, - CornerRadius = new CornerRadius(6), - Background = ThemeResourceHelper.HexBrush(colors.Background), - BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(colors.Border), - BorderThickness = new Thickness(isActive ? 2 : 1), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(2), - }; - - var previewContent = new StackPanel(); - // 헤딩 라인 - previewContent.Children.Add(new Border - { - Width = 60, Height = 6, CornerRadius = new CornerRadius(2), - Background = ThemeResourceHelper.HexBrush(colors.PrimaryText), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 4), - }); - // 악센트 라인 - previewContent.Children.Add(new Border - { - Width = 40, Height = 3, CornerRadius = new CornerRadius(1), - Background = ThemeResourceHelper.HexBrush(colors.Accent), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 6), - }); - // 텍스트 라인들 - for (int i = 0; i < 3; i++) - { - previewContent.Children.Add(new Border - { - Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1), - Background = new SolidColorBrush(ThemeResourceHelper.HexColor(colors.SecondaryText)) { Opacity = 0.5 }, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 0, 0, 3), - }); - } - // 미니 카드 영역 - var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) }; - for (int i = 0; i < 2; i++) - { - cardRow.Children.Add(new Border - { - Width = 28, Height = 14, CornerRadius = new CornerRadius(2), - Background = ThemeResourceHelper.HexBrush(colors.CardBg), - BorderBrush = ThemeResourceHelper.HexBrush(colors.Border), - BorderThickness = new Thickness(0.5), - Margin = new Thickness(0, 0, 4, 0), - }); - } - previewContent.Children.Add(cardRow); - previewCard.Child = previewContent; - - // 무드 라벨 - var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) }; - var labelRow = new StackPanel { Orientation = Orientation.Horizontal }; - labelRow.Children.Add(new TextBlock - { - Text = mood.Icon, FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - labelRow.Children.Add(new TextBlock - { - Text = mood.Label, FontSize = 11.5, - Foreground = primaryText, - FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, - VerticalAlignment = VerticalAlignment.Center, - }); - if (isActive) - { - labelRow.Children.Add(new TextBlock - { - Text = " ✓", FontSize = 11, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - }); - } - labelPanel.Children.Add(labelRow); - - // 전체 카드 래퍼 - var cardWrapper = new Border - { - CornerRadius = new CornerRadius(8), - Background = Brushes.Transparent, - Cursor = Cursors.Hand, - Padding = new Thickness(4), - Margin = new Thickness(2), - }; - var wrapperContent = new StackPanel(); - wrapperContent.Children.Add(previewCard); - wrapperContent.Children.Add(labelPanel); - cardWrapper.Child = wrapperContent; - - // 호버 - cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; - cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - - var capturedMood = mood; - cardWrapper.MouseLeftButtonUp += (_, _) => - { - MoodMenuPopup.IsOpen = false; - _selectedMood = capturedMood.Key; - Llm.DefaultMood = capturedMood.Key; - _settings.Save(); - BuildBottomBar(); - }; - - // 커스텀 무드: 우클릭 - if (isCustom) - { - cardWrapper.MouseRightButtonUp += (s, e) => - { - e.Handled = true; - MoodMenuPopup.IsOpen = false; - ShowCustomMoodContextMenu(s as Border, capturedMood.Key); - }; - } - - grid.Children.Add(cardWrapper); - } - - MoodMenuItems.Children.Add(grid); - - // ── 구분선 + 추가 버튼 ── - MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle - { - Height = 1, - Fill = borderBrush, - Margin = new Thickness(8, 4, 8, 4), - Opacity = 0.4, - }); - - var addSp = new StackPanel { Orientation = Orientation.Horizontal }; - addSp.Children.Add(new TextBlock - { - Text = "\uE710", - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 8, 0), - }); - addSp.Children.Add(new TextBlock - { - Text = "커스텀 무드 추가", - FontSize = 13, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var addBorder = new Border - { - Child = addSp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(8, 6, 12, 6), - }; - ApplyMenuItemHover(addBorder); - addBorder.MouseLeftButtonUp += (_, _) => - { - MoodMenuPopup.IsOpen = false; - ShowCustomMoodDialog(); - }; - MoodMenuItems.Children.Add(addBorder); - - if (FindName("BtnMoodMenu") is UIElement moodTarget) - MoodMenuPopup.PlacementTarget = moodTarget; - MoodMenuPopup.IsOpen = true; - } - - /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. - private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null) - { - bool isEdit = existing != null; - var dlg = new CustomMoodDialog( - existingKey: existing?.Key ?? "", - existingLabel: existing?.Label ?? "", - existingIcon: existing?.Icon ?? "🎯", - existingDesc: existing?.Description ?? "", - existingCss: existing?.Css ?? "") - { - Owner = this, - }; - - if (dlg.ShowDialog() == true) - { - if (isEdit) - { - existing!.Label = dlg.MoodLabel; - existing.Icon = dlg.MoodIcon; - existing.Description = dlg.MoodDescription; - existing.Css = dlg.MoodCss; - } - else - { - Llm.CustomMoods.Add(new Models.CustomMoodEntry - { - Key = dlg.MoodKey, - Label = dlg.MoodLabel, - Icon = dlg.MoodIcon, - Description = dlg.MoodDescription, - Css = dlg.MoodCss, - }); - } - _settings.Save(); - TemplateService.LoadCustomMoods(Llm.CustomMoods); - BuildBottomBar(); - } - } - - /// 커스텀 무드 우클릭 컨텍스트 메뉴. - private void ShowCustomMoodContextMenu(Border? anchor, string moodKey) - { - if (anchor == null) return; - - var popup = new System.Windows.Controls.Primitives.Popup - { - PlacementTarget = anchor, - Placement = System.Windows.Controls.Primitives.PlacementMode.Right, - StaysOpen = false, AllowsTransparency = true, - }; - - var menuBg = ThemeResourceHelper.Background(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var borderBrush = ThemeResourceHelper.Border(this); - - var menuBorder = new Border - { - Background = menuBg, - CornerRadius = new CornerRadius(10), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(4), - MinWidth = 120, - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, - }, - }; - - var stack = new StackPanel(); - - var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText); - editItem.MouseLeftButtonDown += (_, _) => - { - popup.IsOpen = false; - var entry = Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey); - if (entry != null) ShowCustomMoodDialog(entry); - }; - stack.Children.Add(editItem); - - var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText); - deleteItem.MouseLeftButtonDown += (_, _) => - { - popup.IsOpen = false; - var result = CustomMessageBox.Show( - $"이 디자인 무드를 삭제하시겠습니까?", - "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); - if (_selectedMood == moodKey) _selectedMood = "modern"; - _settings.Save(); - TemplateService.LoadCustomMoods(Llm.CustomMoods); - BuildBottomBar(); - } - }; - stack.Children.Add(deleteItem); - - menuBorder.Child = stack; - popup.Child = menuBorder; - popup.IsOpen = true; - } - - - private string? _promptCardPlaceholder; - - private void ShowPlaceholder() - { - if (string.IsNullOrEmpty(_promptCardPlaceholder)) return; - InputWatermark.Text = _promptCardPlaceholder; - InputWatermark.Visibility = Visibility.Visible; - InputBox.Text = ""; - InputBox.Focus(); - } - - private void UpdateWatermarkVisibility() - { - // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) - if (_activeSlashCmd != null) - { - InputWatermark.Visibility = Visibility.Collapsed; - return; - } - - if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text)) - InputWatermark.Visibility = Visibility.Visible; - else - InputWatermark.Visibility = Visibility.Collapsed; - } - - private void ClearPromptCardPlaceholder() - { - _promptCardPlaceholder = null; - InputWatermark.Visibility = Visibility.Collapsed; - } - - private void BtnSettings_Click(object sender, RoutedEventArgs e) - { - // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow - if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) - { - ToggleSettingsPanel(); - return; - } - - if (System.Windows.Application.Current is App app) - app.OpenSettingsFromChat(); - } - - /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글. - private void ToggleSettingsPanel() - { - if (SettingsPanel.IsOpen) - { - SettingsPanel.IsOpen = false; - } - else - { - var activeTab = "Chat"; - if (TabCowork?.IsChecked == true) activeTab = "Cowork"; - else if (TabCode?.IsChecked == true) activeTab = "Code"; - - SettingsPanel.LoadFromSettings(_settings, activeTab); - SettingsPanel.CloseRequested -= OnSettingsPanelClose; - SettingsPanel.CloseRequested += OnSettingsPanelClose; - SettingsPanel.IsOpen = true; - } - } - - private void OnSettingsPanelClose(object? sender, EventArgs e) - { - SettingsPanel.IsOpen = false; - } } diff --git a/src/AxCopilot/Views/ChatWindow.EventBanner.cs b/src/AxCopilot/Views/ChatWindow.EventBanner.cs new file mode 100644 index 0000000..fb7014f --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.EventBanner.cs @@ -0,0 +1,411 @@ +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.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 에이전트 이벤트 배너 ───────────────────────────────────────────── + + private void AddAgentEventBanner(AgentEvent evt) + { + var logLevel = Llm.AgentLogLevel; + + // Planning 이벤트는 단계 목록 카드로 별도 렌더링 + if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 }) + { + AddPlanningCard(evt); + return; + } + + // StepStart 이벤트는 진행률 바 업데이트 + if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0) + { + UpdateProgressBar(evt); + return; + } + + // simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시) + if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall) + return; + + // 전체 통계 이벤트는 별도 색상 (보라색 계열) + var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; + + var (icon, label, bgHex, fgHex) = isTotalStats + ? ("\uE9D2", "Total Stats", "#F3EEFF", "#7C3AED") + : evt.Type switch + { + AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"), + AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"), + AgentEventType.ToolResult => ("\uE73E", evt.ToolName, "#EEF9EE", "#16A34A"), + AgentEventType.SkillCall => ("\uE8A5", evt.ToolName, "#FFF7ED", "#EA580C"), + AgentEventType.Error => ("\uE783", "Error", "#FEF2F2", "#DC2626"), + AgentEventType.Complete => ("\uE930", "Complete", "#F0FFF4", "#15803D"), + AgentEventType.StepDone => ("\uE73E", "Step Done", "#EEF9EE", "#16A34A"), + AgentEventType.Paused => ("\uE769", "Paused", "#FFFBEB", "#D97706"), + AgentEventType.Resumed => ("\uE768", "Resumed", "#ECFDF5", "#059669"), + _ => ("\uE946", "Agent", "#F5F5F5", "#6B7280"), + }; + + var banner = new Border + { + Background = ThemeResourceHelper.HexBrush(bgHex), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(40, 2, 40, 2), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + var sp = new StackPanel(); + + // 헤더: Grid로 좌측(아이콘+라벨) / 우측(타이밍+토큰) 분리 고정 + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 좌측: 아이콘 + 라벨 + var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; + headerLeft.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 11, + Foreground = ThemeResourceHelper.HexBrush(fgHex), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + }); + headerLeft.Children.Add(new TextBlock + { + Text = label, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = ThemeResourceHelper.HexBrush(fgHex), + VerticalAlignment = VerticalAlignment.Center, + }); + Grid.SetColumn(headerLeft, 0); + + // 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정) + var headerRight = new StackPanel { Orientation = Orientation.Horizontal }; + if (logLevel != "simple" && evt.ElapsedMs > 0) + { + headerRight.Children.Add(new TextBlock + { + Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s", + FontSize = 10, + Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }); + } + if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0)) + { + var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0 + ? $"{evt.InputTokens}→{evt.OutputTokens}t" + : evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t"; + headerRight.Children.Add(new Border + { + Background = ThemeResourceHelper.HexBrush("#F0F0F5"), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = tokenText, + FontSize = 9.5, + Foreground = ThemeResourceHelper.HexBrush("#8B8FA3"), + FontFamily = ThemeResourceHelper.Consolas, + }, + }); + } + Grid.SetColumn(headerRight, 1); + + headerGrid.Children.Add(headerLeft); + headerGrid.Children.Add(headerRight); + + // header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용) + var header = headerLeft; + + sp.Children.Add(headerGrid); + + // simple 모드: 요약 한 줄만 표시 (접기 없음) + if (logLevel == "simple") + { + if (!string.IsNullOrEmpty(evt.Summary)) + { + var shortSummary = evt.Summary.Length > 100 + ? evt.Summary[..100] + "…" + : evt.Summary; + sp.Children.Add(new TextBlock + { + Text = shortSummary, + FontSize = 11, + Foreground = ThemeResourceHelper.HexBrush("#6B7280"), + TextWrapping = TextWrapping.NoWrap, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 2, 0, 0), + }); + } + } + // detailed/debug 모드: 기존 접이식 표시 + else if (!string.IsNullOrEmpty(evt.Summary)) + { + var summaryText = evt.Summary; + var isExpandable = (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.ToolResult) + && summaryText.Length > 60; + + if (isExpandable) + { + // 첫 줄만 표시하고 클릭하면 전체 내용 펼침 + var shortText = summaryText.Length > 80 ? summaryText[..80] + "..." : summaryText; + var summaryTb = new TextBlock + { + Text = shortText, + FontSize = 11.5, + Foreground = ThemeResourceHelper.HexBrush("#4B5563"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 3, 0, 0), + Cursor = Cursors.Hand, + }; + + // Diff가 포함된 경우 색상 하이라이팅 적용 + var hasDiff = summaryText.Contains("--- ") && summaryText.Contains("+++ "); + UIElement fullContent; + + if (hasDiff) + { + fullContent = BuildDiffView(summaryText); + } + else + { + fullContent = new TextBlock + { + Text = summaryText, + FontSize = 11, + Foreground = ThemeResourceHelper.HexBrush("#6B7280"), + TextWrapping = TextWrapping.Wrap, + FontFamily = ThemeResourceHelper.Consolas, + }; + } + fullContent.Visibility = Visibility.Collapsed; + ((FrameworkElement)fullContent).Margin = new Thickness(0, 4, 0, 0); + + // 펼침/접기 토글 + var expandIcon = new TextBlock + { + Text = "\uE70D", // ChevronDown + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 9, + Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + header.Children.Add(expandIcon); + + var isExpanded = false; + banner.MouseLeftButtonDown += (_, _) => + { + isExpanded = !isExpanded; + fullContent.Visibility = isExpanded ? Visibility.Visible : Visibility.Collapsed; + summaryTb.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible; + expandIcon.Text = isExpanded ? "\uE70E" : "\uE70D"; // ChevronUp : ChevronDown + }; + + sp.Children.Add(summaryTb); + sp.Children.Add(fullContent); + } + else + { + sp.Children.Add(new TextBlock + { + Text = summaryText, + FontSize = 11.5, + Foreground = ThemeResourceHelper.HexBrush("#4B5563"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 3, 0, 0), + }); + } + } + + // debug 모드: ToolInput 파라미터 표시 + if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput)) + { + sp.Children.Add(new Border + { + Background = ThemeResourceHelper.HexBrush("#F8F8FC"), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 4, 0, 0), + Child = new TextBlock + { + Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput, + FontSize = 10, + Foreground = ThemeResourceHelper.HexBrush("#7C7F93"), + FontFamily = ThemeResourceHelper.Consolas, + TextWrapping = TextWrapping.Wrap, + }, + }); + } + + // 파일 경로 배너 (Claude 스타일) + if (!string.IsNullOrEmpty(evt.FilePath)) + { + var pathBorder = new Border + { + Background = ThemeResourceHelper.HexBrush("#F8FAFC"), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 4, 0, 0), + }; + var pathPanel = new StackPanel { Orientation = Orientation.Horizontal }; + pathPanel.Children.Add(new TextBlock + { + Text = "\uE8B7", // folder icon + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + pathPanel.Children.Add(new TextBlock + { + Text = evt.FilePath, + FontSize = 10.5, + Foreground = ThemeResourceHelper.HexBrush("#6B7280"), + FontFamily = ThemeResourceHelper.Consolas, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + // 빠른 작업 버튼들 + var quickActions = BuildFileQuickActions(evt.FilePath); + pathPanel.Children.Add(quickActions); + + pathBorder.Child = pathPanel; + sp.Children.Add(pathBorder); + } + + banner.Child = sp; + + // Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기 + if (isTotalStats) + { + banner.Cursor = Cursors.Hand; + banner.ToolTip = "클릭하여 병목 분석 보기"; + banner.MouseLeftButtonUp += (_, _) => + { + OpenWorkflowAnalyzerIfEnabled(); + _analyzerWindow?.SwitchToBottleneckTab(); + _analyzerWindow?.Activate(); + }; + } + + // 페이드인 애니메이션 + banner.Opacity = 0; + banner.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); + + MessagePanel.Children.Add(banner); + } + + /// 파일 빠른 작업 버튼 패널을 생성합니다. + private StackPanel BuildFileQuickActions(string filePath) + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + var accentColor = ThemeResourceHelper.HexColor("#3B82F6"); + var accentBrush = new SolidColorBrush(accentColor); + + Border MakeBtn(string mdlIcon, string label, Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = mdlIcon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 9, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 3, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 10, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + var btn = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(4), + Padding = new Thickness(5, 2, 5, 2), + Cursor = Cursors.Hand, + }; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + btn.MouseLeftButtonUp += (_, _) => action(); + return btn; + } + + // 프리뷰 (지원 확장자만) + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + if (_previewableExtensions.Contains(ext)) + { + var path1 = filePath; + panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1))); + } + + // 외부 열기 + var path2 = filePath; + panel.Children.Add(MakeBtn("\uE8A7", "열기", () => + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ } + })); + + // 폴더 열기 + var path3 = filePath; + panel.Children.Add(MakeBtn("\uED25", "폴더", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch (Exception) { /* 탐색기 열기 실패 */ } + })); + + // 경로 복사 + var path4 = filePath; + panel.Children.Add(MakeBtn("\uE8C8", "복사", () => + { + try + { + Clipboard.SetText(path4); + // 1.5초 피드백: "복사됨" 표시 + if (panel.Children[^1] is Border lastBtn && lastBtn.Child is StackPanel lastSp) + { + var origLabel = lastSp.Children.OfType().LastOrDefault(); + if (origLabel != null) + { + var prev = origLabel.Text; + origLabel.Text = "복사됨 ✓"; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; + timer.Tick += (_, _) => { origLabel.Text = prev; timer.Stop(); }; + timer.Start(); + } + } + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + })); + + return panel; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.MoodMenu.cs b/src/AxCopilot/Views/ChatWindow.MoodMenu.cs new file mode 100644 index 0000000..47e5e2c --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.MoodMenu.cs @@ -0,0 +1,456 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 무드 메뉴 / 플레이스홀더 / 설정 패널 ──────────────────────────── + + private string? _promptCardPlaceholder; + + /// 포맷 선택 팝업 메뉴를 표시합니다. + private void ShowFormatMenu() + { + FormatMenuItems.Children.Clear(); + + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var currentFormat = Llm.DefaultOutputFormat ?? "auto"; + + var formats = new (string Key, string Label, string Icon, string Color)[] + { + ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"), + ("xlsx", "Excel", "\uE9F9", "#217346"), + ("html", "HTML 보고서", "\uE12B", "#E44D26"), + ("docx", "Word", "\uE8A5", "#2B579A"), + ("md", "Markdown", "\uE943", "#6B7280"), + ("csv", "CSV", "\uE9D9", "#10B981"), + }; + + foreach (var (key, label, icon, color) in formats) + { + var isActive = key == currentFormat; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + // 커스텀 체크 아이콘 + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, + Foreground = BrushFromHex(color), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + var itemBorder = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 7, 12, 7), + }; + ApplyMenuItemHover(itemBorder); + + var capturedKey = key; + itemBorder.MouseLeftButtonUp += (_, _) => + { + FormatMenuPopup.IsOpen = false; + Llm.DefaultOutputFormat = capturedKey; + _settings.Save(); + BuildBottomBar(); + }; + + FormatMenuItems.Children.Add(itemBorder); + } + + // PlacementTarget을 동적 등록된 버튼으로 설정 + if (FindName("BtnFormatMenu") is UIElement formatTarget) + FormatMenuPopup.PlacementTarget = formatTarget; + FormatMenuPopup.IsOpen = true; + } + + /// 디자인 무드 선택 팝업 메뉴를 표시합니다. + private void ShowMoodMenu() + { + MoodMenuItems.Children.Clear(); + + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var borderBrush = ThemeResourceHelper.Border(this); + + // 2열 갤러리 그리드 + var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; + + foreach (var mood in TemplateService.AllMoods) + { + var isActive = _selectedMood == mood.Key; + var isCustom = Llm.CustomMoods.Any(cm => cm.Key == mood.Key); + var colors = TemplateService.GetMoodColors(mood.Key); + + // 미니 프리뷰 카드 + var previewCard = new Border + { + Width = 160, Height = 80, + CornerRadius = new CornerRadius(6), + Background = ThemeResourceHelper.HexBrush(colors.Background), + BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(colors.Border), + BorderThickness = new Thickness(isActive ? 2 : 1), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(2), + }; + + var previewContent = new StackPanel(); + // 헤딩 라인 + previewContent.Children.Add(new Border + { + Width = 60, Height = 6, CornerRadius = new CornerRadius(2), + Background = ThemeResourceHelper.HexBrush(colors.PrimaryText), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 4), + }); + // 악센트 라인 + previewContent.Children.Add(new Border + { + Width = 40, Height = 3, CornerRadius = new CornerRadius(1), + Background = ThemeResourceHelper.HexBrush(colors.Accent), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 6), + }); + // 텍스트 라인들 + for (int i = 0; i < 3; i++) + { + previewContent.Children.Add(new Border + { + Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1), + Background = new SolidColorBrush(ThemeResourceHelper.HexColor(colors.SecondaryText)) { Opacity = 0.5 }, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 3), + }); + } + // 미니 카드 영역 + var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) }; + for (int i = 0; i < 2; i++) + { + cardRow.Children.Add(new Border + { + Width = 28, Height = 14, CornerRadius = new CornerRadius(2), + Background = ThemeResourceHelper.HexBrush(colors.CardBg), + BorderBrush = ThemeResourceHelper.HexBrush(colors.Border), + BorderThickness = new Thickness(0.5), + Margin = new Thickness(0, 0, 4, 0), + }); + } + previewContent.Children.Add(cardRow); + previewCard.Child = previewContent; + + // 무드 라벨 + var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) }; + var labelRow = new StackPanel { Orientation = Orientation.Horizontal }; + labelRow.Children.Add(new TextBlock + { + Text = mood.Icon, FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + labelRow.Children.Add(new TextBlock + { + Text = mood.Label, FontSize = 11.5, + Foreground = primaryText, + FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, + VerticalAlignment = VerticalAlignment.Center, + }); + if (isActive) + { + labelRow.Children.Add(new TextBlock + { + Text = " ✓", FontSize = 11, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + } + labelPanel.Children.Add(labelRow); + + // 전체 카드 래퍼 + var cardWrapper = new Border + { + CornerRadius = new CornerRadius(8), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + Padding = new Thickness(4), + Margin = new Thickness(2), + }; + var wrapperContent = new StackPanel(); + wrapperContent.Children.Add(previewCard); + wrapperContent.Children.Add(labelPanel); + cardWrapper.Child = wrapperContent; + + // 호버 + cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; + cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + + var capturedMood = mood; + cardWrapper.MouseLeftButtonUp += (_, _) => + { + MoodMenuPopup.IsOpen = false; + _selectedMood = capturedMood.Key; + Llm.DefaultMood = capturedMood.Key; + _settings.Save(); + BuildBottomBar(); + }; + + // 커스텀 무드: 우클릭 + if (isCustom) + { + cardWrapper.MouseRightButtonUp += (s, e) => + { + e.Handled = true; + MoodMenuPopup.IsOpen = false; + ShowCustomMoodContextMenu(s as Border, capturedMood.Key); + }; + } + + grid.Children.Add(cardWrapper); + } + + MoodMenuItems.Children.Add(grid); + + // ── 구분선 + 추가 버튼 ── + MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle + { + Height = 1, + Fill = borderBrush, + Margin = new Thickness(8, 4, 8, 4), + Opacity = 0.4, + }); + + var addSp = new StackPanel { Orientation = Orientation.Horizontal }; + addSp.Children.Add(new TextBlock + { + Text = "\uE710", + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 8, 0), + }); + addSp.Children.Add(new TextBlock + { + Text = "커스텀 무드 추가", + FontSize = 13, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var addBorder = new Border + { + Child = addSp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 6, 12, 6), + }; + ApplyMenuItemHover(addBorder); + addBorder.MouseLeftButtonUp += (_, _) => + { + MoodMenuPopup.IsOpen = false; + ShowCustomMoodDialog(); + }; + MoodMenuItems.Children.Add(addBorder); + + if (FindName("BtnMoodMenu") is UIElement moodTarget) + MoodMenuPopup.PlacementTarget = moodTarget; + MoodMenuPopup.IsOpen = true; + } + + /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. + private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null) + { + bool isEdit = existing != null; + var dlg = new CustomMoodDialog( + existingKey: existing?.Key ?? "", + existingLabel: existing?.Label ?? "", + existingIcon: existing?.Icon ?? "🎯", + existingDesc: existing?.Description ?? "", + existingCss: existing?.Css ?? "") + { + Owner = this, + }; + + if (dlg.ShowDialog() == true) + { + if (isEdit) + { + existing!.Label = dlg.MoodLabel; + existing.Icon = dlg.MoodIcon; + existing.Description = dlg.MoodDescription; + existing.Css = dlg.MoodCss; + } + else + { + Llm.CustomMoods.Add(new Models.CustomMoodEntry + { + Key = dlg.MoodKey, + Label = dlg.MoodLabel, + Icon = dlg.MoodIcon, + Description = dlg.MoodDescription, + Css = dlg.MoodCss, + }); + } + _settings.Save(); + TemplateService.LoadCustomMoods(Llm.CustomMoods); + BuildBottomBar(); + } + } + + /// 커스텀 무드 우클릭 컨텍스트 메뉴. + private void ShowCustomMoodContextMenu(Border? anchor, string moodKey) + { + if (anchor == null) return; + + var popup = new System.Windows.Controls.Primitives.Popup + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Right, + StaysOpen = false, AllowsTransparency = true, + }; + + var menuBg = ThemeResourceHelper.Background(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var borderBrush = ThemeResourceHelper.Border(this); + + var menuBorder = new Border + { + Background = menuBg, + CornerRadius = new CornerRadius(10), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(4), + MinWidth = 120, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, + }, + }; + + var stack = new StackPanel(); + + var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText); + editItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var entry = Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey); + if (entry != null) ShowCustomMoodDialog(entry); + }; + stack.Children.Add(editItem); + + var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText); + deleteItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var result = CustomMessageBox.Show( + $"이 디자인 무드를 삭제하시겠습니까?", + "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); + if (_selectedMood == moodKey) _selectedMood = "modern"; + _settings.Save(); + TemplateService.LoadCustomMoods(Llm.CustomMoods); + BuildBottomBar(); + } + }; + stack.Children.Add(deleteItem); + + menuBorder.Child = stack; + popup.Child = menuBorder; + popup.IsOpen = true; + } + + private void ShowPlaceholder() + { + if (string.IsNullOrEmpty(_promptCardPlaceholder)) return; + InputWatermark.Text = _promptCardPlaceholder; + InputWatermark.Visibility = Visibility.Visible; + InputBox.Text = ""; + InputBox.Focus(); + } + + private void UpdateWatermarkVisibility() + { + // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) + if (_activeSlashCmd != null) + { + InputWatermark.Visibility = Visibility.Collapsed; + return; + } + + if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text)) + InputWatermark.Visibility = Visibility.Visible; + else + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void ClearPromptCardPlaceholder() + { + _promptCardPlaceholder = null; + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void BtnSettings_Click(object sender, RoutedEventArgs e) + { + // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) + { + ToggleSettingsPanel(); + return; + } + + if (System.Windows.Application.Current is App app) + app.OpenSettingsFromChat(); + } + + /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글. + private void ToggleSettingsPanel() + { + if (SettingsPanel.IsOpen) + { + SettingsPanel.IsOpen = false; + } + else + { + var activeTab = "Chat"; + if (TabCowork?.IsChecked == true) activeTab = "Cowork"; + else if (TabCode?.IsChecked == true) activeTab = "Code"; + + SettingsPanel.LoadFromSettings(_settings, activeTab); + SettingsPanel.CloseRequested -= OnSettingsPanelClose; + SettingsPanel.CloseRequested += OnSettingsPanelClose; + SettingsPanel.IsOpen = true; + } + } + + private void OnSettingsPanelClose(object? sender, EventArgs e) + { + SettingsPanel.IsOpen = false; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.PlanViewer.cs b/src/AxCopilot/Views/ChatWindow.PlanViewer.cs new file mode 100644 index 0000000..e7d145b --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.PlanViewer.cs @@ -0,0 +1,474 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 계획 뷰어 ──────────────────────────────────────────────────────── + + private Border? _planningCard; + private StackPanel? _planStepsPanel; + private ProgressBar? _planProgressBar; + private TextBlock? _planProgressText; + + /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). + private void AddPlanningCard(AgentEvent evt) + { + var steps = evt.Steps!; + + var card = new Border + { + Background = ThemeResourceHelper.HexBrush("#F0F4FF"), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 10, 14, 10), + Margin = new Thickness(40, 4, 80, 4), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = 560, + }; + + var sp = new StackPanel(); + + // 헤더 + var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) }; + header.Children.Add(new TextBlock + { + Text = "\uE9D5", // plan icon + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, + Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + }); + header.Children.Add(new TextBlock + { + Text = $"작업 계획 — {steps.Count}단계", + FontSize = 12.5, FontWeight = FontWeights.SemiBold, + Foreground = ThemeResourceHelper.HexBrush("#3730A3"), + VerticalAlignment = VerticalAlignment.Center, + }); + sp.Children.Add(header); + + // 진행률 바 + var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) }; + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + _planProgressBar = new ProgressBar + { + Minimum = 0, + Maximum = steps.Count, + Value = 0, + Height = 4, + Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), + Background = ThemeResourceHelper.HexBrush("#D0D5FF"), + VerticalAlignment = VerticalAlignment.Center, + }; + // Remove the default border on ProgressBar + _planProgressBar.BorderThickness = new Thickness(0); + Grid.SetColumn(_planProgressBar, 0); + progressGrid.Children.Add(_planProgressBar); + + _planProgressText = new TextBlock + { + Text = "0%", + FontSize = 10.5, FontWeight = FontWeights.SemiBold, + Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(_planProgressText, 1); + progressGrid.Children.Add(_planProgressText); + sp.Children.Add(progressGrid); + + // 단계 목록 + _planStepsPanel = new StackPanel(); + for (int i = 0; i < steps.Count; i++) + { + var stepRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 1, 0, 1), + Tag = i, // 인덱스 저장 + }; + + stepRow.Children.Add(new TextBlock + { + Text = "○", // 빈 원 (미완료) + FontSize = 11, + Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + Tag = "status", + }); + stepRow.Children.Add(new TextBlock + { + Text = $"{i + 1}. {steps[i]}", + FontSize = 11.5, + Foreground = ThemeResourceHelper.HexBrush("#4B5563"), + TextWrapping = TextWrapping.Wrap, + MaxWidth = 480, + VerticalAlignment = VerticalAlignment.Center, + }); + + _planStepsPanel.Children.Add(stepRow); + } + sp.Children.Add(_planStepsPanel); + + card.Child = sp; + _planningCard = card; + + // 페이드인 + card.Opacity = 0; + card.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + MessagePanel.Children.Add(card); + } + + /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. + private void AddDecisionButtons(TaskCompletionSource tcs, List options) + { + var accentBrush = ThemeResourceHelper.Accent(this); + var accentColor = ((SolidColorBrush)accentBrush).Color; + var secondaryText = ThemeResourceHelper.Secondary(this); + + var container = new Border + { + Margin = new Thickness(40, 2, 80, 6), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = 560, + }; + + var outerStack = new StackPanel(); + + // 버튼 행 + var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) }; + + // 승인 버튼 (강조) + var approveBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(16), + Padding = new Thickness(16, 7, 16, 7), + Margin = new Thickness(0, 0, 8, 0), + Cursor = Cursors.Hand, + }; + var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; + approveSp.Children.Add(new TextBlock + { + Text = "\uE73E", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, + Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + approveSp.Children.Add(new TextBlock { Text = "승인", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }); + approveBtn.Child = approveSp; + ApplyMenuItemHover(approveBtn); + approveBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); + tcs.TrySetResult(null); // null = 승인 + }; + btnRow.Children.Add(approveBtn); + + // 수정 요청 버튼 + var editBtn = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 0), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + }; + var editSp = new StackPanel { Orientation = Orientation.Horizontal }; + editSp.Children.Add(new TextBlock + { + Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, + Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + editSp.Children.Add(new TextBlock { Text = "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); + editBtn.Child = editSp; + ApplyMenuItemHover(editBtn); + + // 수정 요청용 텍스트 입력 패널 (초기 숨김) + var editInputPanel = new Border + { + Visibility = Visibility.Collapsed, + Background = ThemeResourceHelper.HexBrush("#F8F9FC"), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 8, 0, 0), + }; + var editInputStack = new StackPanel(); + editInputStack.Children.Add(new TextBlock + { + Text = "수정 사항을 입력하세요:", + FontSize = 11.5, Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + var editTextBox = new TextBox + { + MinHeight = 36, + MaxHeight = 100, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 12.5, + Background = Brushes.White, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + Padding = new Thickness(8, 6, 8, 6), + }; + editInputStack.Children.Add(editTextBox); + + var submitEditBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 6, 0, 0), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + }; + submitEditBtn.Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; + ApplyHoverScaleAnimation(submitEditBtn, 1.05); + submitEditBtn.MouseLeftButtonUp += (_, _) => + { + var feedback = editTextBox.Text.Trim(); + if (string.IsNullOrEmpty(feedback)) return; + CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush); + tcs.TrySetResult(feedback); + }; + editInputStack.Children.Add(submitEditBtn); + editInputPanel.Child = editInputStack; + + editBtn.MouseLeftButtonUp += (_, _) => + { + editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible + ? Visibility.Collapsed : Visibility.Visible; + if (editInputPanel.Visibility == Visibility.Visible) + editTextBox.Focus(); + }; + btnRow.Children.Add(editBtn); + + // 취소 버튼 + var cancelBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), + BorderThickness = new Thickness(1), + }; + var cancelSp = new StackPanel { Orientation = Orientation.Horizontal }; + cancelSp.Children.Add(new TextBlock + { + Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + cancelSp.Children.Add(new TextBlock + { + Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + }); + cancelBtn.Child = cancelSp; + ApplyMenuItemHover(cancelBtn); + cancelBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✕ 취소됨", + new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))); + tcs.TrySetResult("취소"); + }; + btnRow.Children.Add(cancelBtn); + + outerStack.Children.Add(btnRow); + outerStack.Children.Add(editInputPanel); + container.Child = outerStack; + + // 슬라이드 + 페이드 등장 애니메이션 + ApplyMessageEntryAnimation(container); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 + + // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 + var capturedOuterStack = outerStack; + var capturedAccent = accentBrush; + _ = tcs.Task.ContinueWith(t => + { + Dispatcher.BeginInvoke(() => + { + // 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우) + if (capturedOuterStack.Children.Count <= 1) return; + + var label = t.Result == null ? "✓ 승인됨" + : t.Result == "취소" ? "✕ 취소됨" + : "✎ 수정 요청됨"; + var fg = t.Result == "취소" + ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)) + : capturedAccent; + CollapseDecisionButtons(capturedOuterStack, label, fg); + }); + }, TaskScheduler.Default); + } + + /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. + private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) + { + outerStack.Children.Clear(); + var resultLabel = new TextBlock + { + Text = resultText, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = fg, + Opacity = 0.8, + Margin = new Thickness(0, 2, 0, 2), + }; + outerStack.Children.Add(resultLabel); + } + + // ════════════════════════════════════════════════════════════ + // 실행 계획 뷰어 (PlanViewerWindow) 연동 + // ════════════════════════════════════════════════════════════ + + /// PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다. + private Func, Task> CreatePlanDecisionCallback() + { + return async (planSummary, options) => + { + var tcs = new TaskCompletionSource(); + var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary); + + await Dispatcher.InvokeAsync(() => + { + // PlanViewerWindow 생성 또는 재사용 + if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) + { + _planViewerWindow = new PlanViewerWindow(); + _planViewerWindow.Closing += (_, e) => + { + e.Cancel = true; + _planViewerWindow.Hide(); + }; + } + + // 계획 표시 + 승인 대기 + _planViewerWindow.ShowPlanAsync(planSummary, steps, tcs); + + // 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시 + AddDecisionButtons(tcs, options); + + // 하단 바 계획 버튼 표시 + ShowPlanButton(true); + }); + + // 5분 타임아웃 + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); + if (completed != tcs.Task) + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + return "취소"; + } + + var result = await tcs.Task; + + // 승인된 경우 — 실행 모드로 전환 + if (result == null) // null = 승인 + { + await Dispatcher.InvokeAsync(() => + { + _planViewerWindow?.SwitchToExecutionMode(); + _planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기 + }); + } + else + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + } + + return result; + }; + } + + /// 하단 바에 계획 보기 버튼을 표시/숨김합니다. + private void ShowPlanButton(bool show) + { + if (!show) + { + // 계획 버튼 제거 + for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) + { + if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") + { + // 앞의 구분선도 제거 + if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep") + MoodIconPanel.Children.RemoveAt(i - 1); + if (i < MoodIconPanel.Children.Count) + MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1)); + break; + } + } + return; + } + + // 이미 있으면 무시 + foreach (var child in MoodIconPanel.Children) + { + if (child is Border b && b.Tag?.ToString() == "PlanBtn") return; + } + + // 구분선 + var separator = new Border + { + Width = 1, Height = 18, + Background = ThemeResourceHelper.Separator(this), + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + Tag = "PlanSep", + }; + MoodIconPanel.Children.Add(separator); + + // 계획 버튼 + var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981"); + planBtn.Tag = "PlanBtn"; + planBtn.MouseLeftButtonUp += (_, e) => + { + e.Handled = true; + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + { + _planViewerWindow.Show(); + _planViewerWindow.Activate(); + } + }; + MoodIconPanel.Children.Add(planBtn); + } + + /// 계획 뷰어에서 현재 실행 단계를 갱신합니다. + private void UpdatePlanViewerStep(AgentEvent evt) + { + if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return; + if (evt.StepCurrent > 0) + _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based + } + + /// 계획 실행 완료를 뷰어에 알립니다. + private void CompletePlanViewer() + { + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + _planViewerWindow.MarkComplete(); + ShowPlanButton(false); + } + + private static bool IsWindowAlive(Window? w) + { + if (w == null) return false; + try { var _ = w.IsVisible; return true; } + catch (Exception) { return false; } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.TaskDecomposition.cs b/src/AxCopilot/Views/ChatWindow.TaskDecomposition.cs index d9bad03..941737f 100644 --- a/src/AxCopilot/Views/ChatWindow.TaskDecomposition.cs +++ b/src/AxCopilot/Views/ChatWindow.TaskDecomposition.cs @@ -1,12 +1,7 @@ using System.Windows; using System.Windows.Controls; -using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Animation; -using System.Windows.Threading; -using AxCopilot.Models; -using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; @@ -15,467 +10,6 @@ public partial class ChatWindow { // ─── Task Decomposition UI ──────────────────────────────────────────── - private Border? _planningCard; - private StackPanel? _planStepsPanel; - private ProgressBar? _planProgressBar; - private TextBlock? _planProgressText; - - /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). - private void AddPlanningCard(AgentEvent evt) - { - var steps = evt.Steps!; - - var card = new Border - { - Background = ThemeResourceHelper.HexBrush("#F0F4FF"), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(14, 10, 14, 10), - Margin = new Thickness(40, 4, 80, 4), - HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = 560, - }; - - var sp = new StackPanel(); - - // 헤더 - var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) }; - header.Children.Add(new TextBlock - { - Text = "\uE9D5", // plan icon - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, - Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), - }); - header.Children.Add(new TextBlock - { - Text = $"작업 계획 — {steps.Count}단계", - FontSize = 12.5, FontWeight = FontWeights.SemiBold, - Foreground = ThemeResourceHelper.HexBrush("#3730A3"), - VerticalAlignment = VerticalAlignment.Center, - }); - sp.Children.Add(header); - - // 진행률 바 - var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) }; - progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - _planProgressBar = new ProgressBar - { - Minimum = 0, - Maximum = steps.Count, - Value = 0, - Height = 4, - Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), - Background = ThemeResourceHelper.HexBrush("#D0D5FF"), - VerticalAlignment = VerticalAlignment.Center, - }; - // Remove the default border on ProgressBar - _planProgressBar.BorderThickness = new Thickness(0); - Grid.SetColumn(_planProgressBar, 0); - progressGrid.Children.Add(_planProgressBar); - - _planProgressText = new TextBlock - { - Text = "0%", - FontSize = 10.5, FontWeight = FontWeights.SemiBold, - Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"), - Margin = new Thickness(8, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(_planProgressText, 1); - progressGrid.Children.Add(_planProgressText); - sp.Children.Add(progressGrid); - - // 단계 목록 - _planStepsPanel = new StackPanel(); - for (int i = 0; i < steps.Count; i++) - { - var stepRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(0, 1, 0, 1), - Tag = i, // 인덱스 저장 - }; - - stepRow.Children.Add(new TextBlock - { - Text = "○", // 빈 원 (미완료) - FontSize = 11, - Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), - Tag = "status", - }); - stepRow.Children.Add(new TextBlock - { - Text = $"{i + 1}. {steps[i]}", - FontSize = 11.5, - Foreground = ThemeResourceHelper.HexBrush("#4B5563"), - TextWrapping = TextWrapping.Wrap, - MaxWidth = 480, - VerticalAlignment = VerticalAlignment.Center, - }); - - _planStepsPanel.Children.Add(stepRow); - } - sp.Children.Add(_planStepsPanel); - - card.Child = sp; - _planningCard = card; - - // 페이드인 - card.Opacity = 0; - card.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - - MessagePanel.Children.Add(card); - } - - /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. - private void AddDecisionButtons(TaskCompletionSource tcs, List options) - { - var accentBrush = ThemeResourceHelper.Accent(this); - var accentColor = ((SolidColorBrush)accentBrush).Color; - var secondaryText = ThemeResourceHelper.Secondary(this); - - var container = new Border - { - Margin = new Thickness(40, 2, 80, 6), - HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = 560, - }; - - var outerStack = new StackPanel(); - - // 버튼 행 - var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) }; - - // 승인 버튼 (강조) - var approveBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(16, 7, 16, 7), - Margin = new Thickness(0, 0, 8, 0), - Cursor = Cursors.Hand, - }; - var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; - approveSp.Children.Add(new TextBlock - { - Text = "\uE73E", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, - Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - approveSp.Children.Add(new TextBlock { Text = "승인", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }); - approveBtn.Child = approveSp; - ApplyMenuItemHover(approveBtn); - approveBtn.MouseLeftButtonUp += (_, _) => - { - CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); - tcs.TrySetResult(null); // null = 승인 - }; - btnRow.Children.Add(approveBtn); - - // 수정 요청 버튼 - var editBtn = new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Margin = new Thickness(0, 0, 8, 0), - Cursor = Cursors.Hand, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), - BorderThickness = new Thickness(1), - }; - var editSp = new StackPanel { Orientation = Orientation.Horizontal }; - editSp.Children.Add(new TextBlock - { - Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, - Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - editSp.Children.Add(new TextBlock { Text = "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); - editBtn.Child = editSp; - ApplyMenuItemHover(editBtn); - - // 수정 요청용 텍스트 입력 패널 (초기 숨김) - var editInputPanel = new Border - { - Visibility = Visibility.Collapsed, - Background = ThemeResourceHelper.HexBrush("#F8F9FC"), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 8, 0, 0), - }; - var editInputStack = new StackPanel(); - editInputStack.Children.Add(new TextBlock - { - Text = "수정 사항을 입력하세요:", - FontSize = 11.5, Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 6), - }); - var editTextBox = new TextBox - { - MinHeight = 36, - MaxHeight = 100, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - FontSize = 12.5, - Background = Brushes.White, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), - BorderThickness = new Thickness(1), - Padding = new Thickness(8, 6, 8, 6), - }; - editInputStack.Children.Add(editTextBox); - - var submitEditBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 5, 12, 5), - Margin = new Thickness(0, 6, 0, 0), - Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, - }; - submitEditBtn.Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; - ApplyHoverScaleAnimation(submitEditBtn, 1.05); - submitEditBtn.MouseLeftButtonUp += (_, _) => - { - var feedback = editTextBox.Text.Trim(); - if (string.IsNullOrEmpty(feedback)) return; - CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush); - tcs.TrySetResult(feedback); - }; - editInputStack.Children.Add(submitEditBtn); - editInputPanel.Child = editInputStack; - - editBtn.MouseLeftButtonUp += (_, _) => - { - editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible - ? Visibility.Collapsed : Visibility.Visible; - if (editInputPanel.Visibility == Visibility.Visible) - editTextBox.Focus(); - }; - btnRow.Children.Add(editBtn); - - // 취소 버튼 - var cancelBtn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Cursor = Cursors.Hand, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), - BorderThickness = new Thickness(1), - }; - var cancelSp = new StackPanel { Orientation = Orientation.Horizontal }; - cancelSp.Children.Add(new TextBlock - { - Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, - Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), - }); - cancelSp.Children.Add(new TextBlock - { - Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), - }); - cancelBtn.Child = cancelSp; - ApplyMenuItemHover(cancelBtn); - cancelBtn.MouseLeftButtonUp += (_, _) => - { - CollapseDecisionButtons(outerStack, "✕ 취소됨", - new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))); - tcs.TrySetResult("취소"); - }; - btnRow.Children.Add(cancelBtn); - - outerStack.Children.Add(btnRow); - outerStack.Children.Add(editInputPanel); - container.Child = outerStack; - - // 슬라이드 + 페이드 등장 애니메이션 - ApplyMessageEntryAnimation(container); - MessagePanel.Children.Add(container); - ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 - - // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 - var capturedOuterStack = outerStack; - var capturedAccent = accentBrush; - _ = tcs.Task.ContinueWith(t => - { - Dispatcher.BeginInvoke(() => - { - // 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우) - if (capturedOuterStack.Children.Count <= 1) return; - - var label = t.Result == null ? "✓ 승인됨" - : t.Result == "취소" ? "✕ 취소됨" - : "✎ 수정 요청됨"; - var fg = t.Result == "취소" - ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)) - : capturedAccent; - CollapseDecisionButtons(capturedOuterStack, label, fg); - }); - }, TaskScheduler.Default); - } - - /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. - private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) - { - outerStack.Children.Clear(); - var resultLabel = new TextBlock - { - Text = resultText, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = fg, - Opacity = 0.8, - Margin = new Thickness(0, 2, 0, 2), - }; - outerStack.Children.Add(resultLabel); - } - - // ════════════════════════════════════════════════════════════ - // 실행 계획 뷰어 (PlanViewerWindow) 연동 - // ════════════════════════════════════════════════════════════ - - /// PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다. - private Func, Task> CreatePlanDecisionCallback() - { - return async (planSummary, options) => - { - var tcs = new TaskCompletionSource(); - var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary); - - await Dispatcher.InvokeAsync(() => - { - // PlanViewerWindow 생성 또는 재사용 - if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) - { - _planViewerWindow = new PlanViewerWindow(); - _planViewerWindow.Closing += (_, e) => - { - e.Cancel = true; - _planViewerWindow.Hide(); - }; - } - - // 계획 표시 + 승인 대기 - _planViewerWindow.ShowPlanAsync(planSummary, steps, tcs); - - // 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시 - AddDecisionButtons(tcs, options); - - // 하단 바 계획 버튼 표시 - ShowPlanButton(true); - }); - - // 5분 타임아웃 - var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); - if (completed != tcs.Task) - { - await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); - return "취소"; - } - - var result = await tcs.Task; - - // 승인된 경우 — 실행 모드로 전환 - if (result == null) // null = 승인 - { - await Dispatcher.InvokeAsync(() => - { - _planViewerWindow?.SwitchToExecutionMode(); - _planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기 - }); - } - else - { - await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); - } - - return result; - }; - } - - /// 하단 바에 계획 보기 버튼을 표시/숨김합니다. - private void ShowPlanButton(bool show) - { - if (!show) - { - // 계획 버튼 제거 - for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) - { - if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") - { - // 앞의 구분선도 제거 - if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep") - MoodIconPanel.Children.RemoveAt(i - 1); - if (i < MoodIconPanel.Children.Count) - MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1)); - break; - } - } - return; - } - - // 이미 있으면 무시 - foreach (var child in MoodIconPanel.Children) - { - if (child is Border b && b.Tag?.ToString() == "PlanBtn") return; - } - - // 구분선 - var separator = new Border - { - Width = 1, Height = 18, - Background = ThemeResourceHelper.Separator(this), - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - Tag = "PlanSep", - }; - MoodIconPanel.Children.Add(separator); - - // 계획 버튼 - var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981"); - planBtn.Tag = "PlanBtn"; - planBtn.MouseLeftButtonUp += (_, e) => - { - e.Handled = true; - if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) - { - _planViewerWindow.Show(); - _planViewerWindow.Activate(); - } - }; - MoodIconPanel.Children.Add(planBtn); - } - - /// 계획 뷰어에서 현재 실행 단계를 갱신합니다. - private void UpdatePlanViewerStep(AgentEvent evt) - { - if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return; - if (evt.StepCurrent > 0) - _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based - } - - /// 계획 실행 완료를 뷰어에 알립니다. - private void CompletePlanViewer() - { - if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) - _planViewerWindow.MarkComplete(); - ShowPlanButton(false); - } - - private static bool IsWindowAlive(Window? w) - { - if (w == null) return false; - try { var _ = w.IsVisible; return true; } - catch (Exception) { return false; } - } - // ════════════════════════════════════════════════════════════ // 후속 작업 제안 칩 (suggest_actions) // ════════════════════════════════════════════════════════════ @@ -770,401 +304,4 @@ public partial class ChatWindow return panel; } - - private void AddAgentEventBanner(AgentEvent evt) - { - var logLevel = Llm.AgentLogLevel; - - // Planning 이벤트는 단계 목록 카드로 별도 렌더링 - if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 }) - { - AddPlanningCard(evt); - return; - } - - // StepStart 이벤트는 진행률 바 업데이트 - if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0) - { - UpdateProgressBar(evt); - return; - } - - // simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시) - if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall) - return; - - // 전체 통계 이벤트는 별도 색상 (보라색 계열) - var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; - - var (icon, label, bgHex, fgHex) = isTotalStats - ? ("\uE9D2", "Total Stats", "#F3EEFF", "#7C3AED") - : evt.Type switch - { - AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"), - AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"), - AgentEventType.ToolResult => ("\uE73E", evt.ToolName, "#EEF9EE", "#16A34A"), - AgentEventType.SkillCall => ("\uE8A5", evt.ToolName, "#FFF7ED", "#EA580C"), - AgentEventType.Error => ("\uE783", "Error", "#FEF2F2", "#DC2626"), - AgentEventType.Complete => ("\uE930", "Complete", "#F0FFF4", "#15803D"), - AgentEventType.StepDone => ("\uE73E", "Step Done", "#EEF9EE", "#16A34A"), - AgentEventType.Paused => ("\uE769", "Paused", "#FFFBEB", "#D97706"), - AgentEventType.Resumed => ("\uE768", "Resumed", "#ECFDF5", "#059669"), - _ => ("\uE946", "Agent", "#F5F5F5", "#6B7280"), - }; - - var banner = new Border - { - Background = ThemeResourceHelper.HexBrush(bgHex), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(40, 2, 40, 2), - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - - var sp = new StackPanel(); - - // 헤더: Grid로 좌측(아이콘+라벨) / 우측(타이밍+토큰) 분리 고정 - var headerGrid = new Grid(); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - // 좌측: 아이콘 + 라벨 - var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; - headerLeft.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 11, - Foreground = ThemeResourceHelper.HexBrush(fgHex), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), - }); - headerLeft.Children.Add(new TextBlock - { - Text = label, - FontSize = 11.5, - FontWeight = FontWeights.SemiBold, - Foreground = ThemeResourceHelper.HexBrush(fgHex), - VerticalAlignment = VerticalAlignment.Center, - }); - Grid.SetColumn(headerLeft, 0); - - // 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정) - var headerRight = new StackPanel { Orientation = Orientation.Horizontal }; - if (logLevel != "simple" && evt.ElapsedMs > 0) - { - headerRight.Children.Add(new TextBlock - { - Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s", - FontSize = 10, - Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(8, 0, 0, 0), - }); - } - if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0)) - { - var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0 - ? $"{evt.InputTokens}→{evt.OutputTokens}t" - : evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t"; - headerRight.Children.Add(new Border - { - Background = ThemeResourceHelper.HexBrush("#F0F0F5"), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(6, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = tokenText, - FontSize = 9.5, - Foreground = ThemeResourceHelper.HexBrush("#8B8FA3"), - FontFamily = ThemeResourceHelper.Consolas, - }, - }); - } - Grid.SetColumn(headerRight, 1); - - headerGrid.Children.Add(headerLeft); - headerGrid.Children.Add(headerRight); - - // header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용) - var header = headerLeft; - - sp.Children.Add(headerGrid); - - // simple 모드: 요약 한 줄만 표시 (접기 없음) - if (logLevel == "simple") - { - if (!string.IsNullOrEmpty(evt.Summary)) - { - var shortSummary = evt.Summary.Length > 100 - ? evt.Summary[..100] + "…" - : evt.Summary; - sp.Children.Add(new TextBlock - { - Text = shortSummary, - FontSize = 11, - Foreground = ThemeResourceHelper.HexBrush("#6B7280"), - TextWrapping = TextWrapping.NoWrap, - TextTrimming = TextTrimming.CharacterEllipsis, - Margin = new Thickness(0, 2, 0, 0), - }); - } - } - // detailed/debug 모드: 기존 접이식 표시 - else if (!string.IsNullOrEmpty(evt.Summary)) - { - var summaryText = evt.Summary; - var isExpandable = (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.ToolResult) - && summaryText.Length > 60; - - if (isExpandable) - { - // 첫 줄만 표시하고 클릭하면 전체 내용 펼침 - var shortText = summaryText.Length > 80 ? summaryText[..80] + "..." : summaryText; - var summaryTb = new TextBlock - { - Text = shortText, - FontSize = 11.5, - Foreground = ThemeResourceHelper.HexBrush("#4B5563"), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 3, 0, 0), - Cursor = Cursors.Hand, - }; - - // Diff가 포함된 경우 색상 하이라이팅 적용 - var hasDiff = summaryText.Contains("--- ") && summaryText.Contains("+++ "); - UIElement fullContent; - - if (hasDiff) - { - fullContent = BuildDiffView(summaryText); - } - else - { - fullContent = new TextBlock - { - Text = summaryText, - FontSize = 11, - Foreground = ThemeResourceHelper.HexBrush("#6B7280"), - TextWrapping = TextWrapping.Wrap, - FontFamily = ThemeResourceHelper.Consolas, - }; - } - fullContent.Visibility = Visibility.Collapsed; - ((FrameworkElement)fullContent).Margin = new Thickness(0, 4, 0, 0); - - // 펼침/접기 토글 - var expandIcon = new TextBlock - { - Text = "\uE70D", // ChevronDown - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 9, - Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), - Margin = new Thickness(6, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - header.Children.Add(expandIcon); - - var isExpanded = false; - banner.MouseLeftButtonDown += (_, _) => - { - isExpanded = !isExpanded; - fullContent.Visibility = isExpanded ? Visibility.Visible : Visibility.Collapsed; - summaryTb.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible; - expandIcon.Text = isExpanded ? "\uE70E" : "\uE70D"; // ChevronUp : ChevronDown - }; - - sp.Children.Add(summaryTb); - sp.Children.Add(fullContent); - } - else - { - sp.Children.Add(new TextBlock - { - Text = summaryText, - FontSize = 11.5, - Foreground = ThemeResourceHelper.HexBrush("#4B5563"), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 3, 0, 0), - }); - } - } - - // debug 모드: ToolInput 파라미터 표시 - if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput)) - { - sp.Children.Add(new Border - { - Background = ThemeResourceHelper.HexBrush("#F8F8FC"), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 4, 0, 0), - Child = new TextBlock - { - Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput, - FontSize = 10, - Foreground = ThemeResourceHelper.HexBrush("#7C7F93"), - FontFamily = ThemeResourceHelper.Consolas, - TextWrapping = TextWrapping.Wrap, - }, - }); - } - - // 파일 경로 배너 (Claude 스타일) - if (!string.IsNullOrEmpty(evt.FilePath)) - { - var pathBorder = new Border - { - Background = ThemeResourceHelper.HexBrush("#F8FAFC"), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 4, 0, 0), - }; - var pathPanel = new StackPanel { Orientation = Orientation.Horizontal }; - pathPanel.Children.Add(new TextBlock - { - Text = "\uE8B7", // folder icon - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, - Foreground = ThemeResourceHelper.HexBrush("#9CA3AF"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - pathPanel.Children.Add(new TextBlock - { - Text = evt.FilePath, - FontSize = 10.5, - Foreground = ThemeResourceHelper.HexBrush("#6B7280"), - FontFamily = ThemeResourceHelper.Consolas, - VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - }); - - // 빠른 작업 버튼들 - var quickActions = BuildFileQuickActions(evt.FilePath); - pathPanel.Children.Add(quickActions); - - pathBorder.Child = pathPanel; - sp.Children.Add(pathBorder); - } - - banner.Child = sp; - - // Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기 - if (isTotalStats) - { - banner.Cursor = Cursors.Hand; - banner.ToolTip = "클릭하여 병목 분석 보기"; - banner.MouseLeftButtonUp += (_, _) => - { - OpenWorkflowAnalyzerIfEnabled(); - _analyzerWindow?.SwitchToBottleneckTab(); - _analyzerWindow?.Activate(); - }; - } - - // 페이드인 애니메이션 - banner.Opacity = 0; - banner.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); - - MessagePanel.Children.Add(banner); - } - - /// 파일 빠른 작업 버튼 패널을 생성합니다. - private StackPanel BuildFileQuickActions(string filePath) - { - var panel = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(6, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - - var accentColor = ThemeResourceHelper.HexColor("#3B82F6"); - var accentBrush = new SolidColorBrush(accentColor); - - Border MakeBtn(string mdlIcon, string label, Action action) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = mdlIcon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 9, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 3, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 10, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - }); - var btn = new Border - { - Child = sp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 2, 5, 2), - Cursor = Cursors.Hand, - }; - btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; - btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - btn.MouseLeftButtonUp += (_, _) => action(); - return btn; - } - - // 프리뷰 (지원 확장자만) - var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); - if (_previewableExtensions.Contains(ext)) - { - var path1 = filePath; - panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1))); - } - - // 외부 열기 - var path2 = filePath; - panel.Children.Add(MakeBtn("\uE8A7", "열기", () => - { - try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ } - })); - - // 폴더 열기 - var path3 = filePath; - panel.Children.Add(MakeBtn("\uED25", "폴더", () => - { - try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch (Exception) { /* 탐색기 열기 실패 */ } - })); - - // 경로 복사 - var path4 = filePath; - panel.Children.Add(MakeBtn("\uE8C8", "복사", () => - { - try - { - Clipboard.SetText(path4); - // 1.5초 피드백: "복사됨" 표시 - if (panel.Children[^1] is Border lastBtn && lastBtn.Child is StackPanel lastSp) - { - var origLabel = lastSp.Children.OfType().LastOrDefault(); - if (origLabel != null) - { - var prev = origLabel.Text; - origLabel.Text = "복사됨 ✓"; - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; - timer.Tick += (_, _) => { origLabel.Text = prev; timer.Stop(); }; - timer.Start(); - } - } - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - })); - - return panel; - } } diff --git a/src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs b/src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs new file mode 100644 index 0000000..b5a52dc --- /dev/null +++ b/src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs @@ -0,0 +1,616 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +internal sealed partial class PlanViewerWindow +{ + // ════════════════════════════════════════════════════════════ + // 단계 목록 렌더링 + // ════════════════════════════════════════════════════════════ + + private void RenderSteps() + { + _stepsPanel.Children.Clear(); + + var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + var canEdit = !_isExecuting && _currentStep < 0; // 승인 대기 중에만 편집/순서변경 가능 + + for (int i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var capturedIdx = i; + var isComplete = i < _currentStep; + var isCurrent = i == _currentStep; + var isPending = i > _currentStep; + var isExpanded = _expandedSteps.Contains(i); + + // ─ 카드 Border ─ + var card = new Border + { + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 0, 0, 5), + Background = isCurrent + ? new SolidColorBrush(Color.FromArgb(0x18, + ((SolidColorBrush)accentBrush).Color.R, + ((SolidColorBrush)accentBrush).Color.G, + ((SolidColorBrush)accentBrush).Color.B)) + : itemBg, + BorderBrush = isCurrent ? accentBrush : Brushes.Transparent, + BorderThickness = new Thickness(isCurrent ? 1.5 : 0), + AllowDrop = canEdit, + }; + + // 열기/닫기 토글 — 텍스트 또는 배경 클릭 + card.Cursor = Cursors.Hand; + card.MouseLeftButtonUp += (s, e) => + { + // 드래그 직후 클릭이 발생하는 경우 무시 + if (e.OriginalSource is Border src && src.Tag?.ToString() == "DragHandle") return; + if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx); + else _expandedSteps.Add(capturedIdx); + RenderSteps(); + }; + + // ─ 카드 Grid: [drag?][badge][*text][chevron][edit?] ─ + var cardGrid = new Grid(); + int badgeCol = canEdit ? 1 : 0; + int textCol = canEdit ? 2 : 1; + int chevCol = canEdit ? 3 : 2; + int editCol = canEdit ? 4 : -1; + + if (canEdit) + cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // drag + cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // badge + cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text + cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // chevron + if (canEdit) + cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // edit btns + + // ── 드래그 핸들 (편집 모드 전용) ── + if (canEdit) + { + var dimColor = Color.FromArgb(0x55, 0x80, 0x80, 0x80); + var dimBrush = new SolidColorBrush(dimColor); + var dragHandle = new Border + { + Tag = "DragHandle", + Width = 20, Cursor = Cursors.SizeAll, + Background = Brushes.Transparent, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "\uE8FD", // Sort/Lines 아이콘 (드래그 핸들) + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 11, + Foreground = dimBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + dragHandle.MouseEnter += (s, _) => + ((TextBlock)((Border)s).Child).Foreground = secondaryText; + dragHandle.MouseLeave += (s, _) => + ((TextBlock)((Border)s).Child).Foreground = dimBrush; + + // 드래그 시작 — 마우스 눌림 위치 기록 + dragHandle.PreviewMouseLeftButtonDown += (s, e) => + { + _dragSourceIndex = capturedIdx; + _dragStartPoint = e.GetPosition(_stepsPanel); + e.Handled = true; // 카드 클릭(expand) 이벤트 방지 + }; + // 충분히 움직이면 DragDrop 시작 + dragHandle.PreviewMouseMove += (s, e) => + { + if (_dragSourceIndex < 0 || e.LeftButton != MouseButtonState.Pressed) return; + var cur = e.GetPosition(_stepsPanel); + if (Math.Abs(cur.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance || + Math.Abs(cur.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance) + { + int idx = _dragSourceIndex; + _dragSourceIndex = -1; + DragDrop.DoDragDrop((DependencyObject)s, + new DataObject(DragDataFormat, idx), DragDropEffects.Move); + // DoDragDrop 완료 후 비주얼 정리 + Dispatcher.InvokeAsync(RenderSteps); + } + }; + dragHandle.PreviewMouseLeftButtonUp += (_, _) => _dragSourceIndex = -1; + + Grid.SetColumn(dragHandle, 0); + cardGrid.Children.Add(dragHandle); + + // ── 카드 Drop 이벤트 ── + card.DragOver += (s, e) => + { + if (!e.Data.GetDataPresent(DragDataFormat)) return; + int src = (int)e.Data.GetData(DragDataFormat); + if (src != capturedIdx) + { + ((Border)s).BorderBrush = accentBrush; + ((Border)s).BorderThickness = new Thickness(1.5); + e.Effects = DragDropEffects.Move; + } + else e.Effects = DragDropEffects.None; + e.Handled = true; + }; + card.DragLeave += (s, _) => + { + bool isCurr = _currentStep == capturedIdx; + ((Border)s).BorderBrush = isCurr ? accentBrush : Brushes.Transparent; + ((Border)s).BorderThickness = new Thickness(isCurr ? 1.5 : 0); + }; + card.Drop += (s, e) => + { + if (!e.Data.GetDataPresent(DragDataFormat)) { e.Handled = true; return; } + int srcIdx = (int)e.Data.GetData(DragDataFormat); + int dstIdx = capturedIdx; + if (srcIdx != dstIdx && srcIdx >= 0 && srcIdx < _steps.Count) + { + var item = _steps[srcIdx]; + _steps.RemoveAt(srcIdx); + // srcIdx < dstIdx 이면 제거 후 인덱스가 1 감소 + int insertAt = srcIdx < dstIdx ? dstIdx - 1 : dstIdx; + _steps.Insert(insertAt, item); + _expandedSteps.Clear(); + RenderSteps(); + } + e.Handled = true; + }; + } + + // ── 상태 배지 ── + UIElement badge; + if (isComplete) + { + badge = new TextBlock + { + Text = "\uE73E", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, Foreground = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + Width = 20, TextAlignment = TextAlignment.Center, + }; + } + else if (isCurrent) + { + badge = new TextBlock + { + Text = "\uE768", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + Width = 20, TextAlignment = TextAlignment.Center, + }; + } + else + { + badge = new Border + { + Width = 22, Height = 22, CornerRadius = new CornerRadius(11), + Background = new SolidColorBrush(Color.FromArgb(0x25, 0x80, 0x80, 0x80)), + Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"{i + 1}", FontSize = 11, Foreground = secondaryText, + FontWeight = FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + } + Grid.SetColumn(badge, badgeCol); + cardGrid.Children.Add(badge); + + // ── 단계 텍스트 ── + var textBlock = new TextBlock + { + Text = step, + FontSize = 13, + Foreground = isComplete ? secondaryText : primaryText, + VerticalAlignment = VerticalAlignment.Center, + Opacity = isPending && _isExecuting ? 0.6 : 1.0, + TextDecorations = isComplete ? TextDecorations.Strikethrough : null, + Margin = new Thickness(0, 0, 4, 0), + }; + if (isExpanded) + { + textBlock.TextWrapping = TextWrapping.Wrap; + textBlock.TextTrimming = TextTrimming.None; + } + else + { + textBlock.TextWrapping = TextWrapping.NoWrap; + textBlock.TextTrimming = TextTrimming.CharacterEllipsis; + textBlock.ToolTip = step; // 접힌 상태: 호버 시 전체 텍스트 툴팁 + } + Grid.SetColumn(textBlock, textCol); + cardGrid.Children.Add(textBlock); + + // ── 펼침/접힘 Chevron ── + var chevron = new Border + { + Width = 22, Height = 22, CornerRadius = new CornerRadius(4), + Background = Brushes.Transparent, Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, canEdit ? 4 : 0, 0), + Child = new TextBlock + { + Text = isExpanded ? "\uE70E" : "\uE70D", // ChevronUp / ChevronDown + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 9, + Foreground = new SolidColorBrush(Color.FromArgb(0x70, 0x80, 0x80, 0x80)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + chevron.MouseEnter += (s, _) => ((Border)s).Background = hoverBg; + chevron.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + chevron.MouseLeftButtonUp += (_, e) => + { + if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx); + else _expandedSteps.Add(capturedIdx); + RenderSteps(); + e.Handled = true; + }; + Grid.SetColumn(chevron, chevCol); + cardGrid.Children.Add(chevron); + + // ── 편집 버튼 (위/아래/편집/삭제) ── + if (canEdit) + { + var editBtnPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(2, 0, 0, 0), + }; + + if (i > 0) + { + var upBtn = CreateMiniButton("\uE70E", secondaryText, hoverBg); + upBtn.ToolTip = "위로 이동"; + upBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx - 1); e.Handled = true; }; + editBtnPanel.Children.Add(upBtn); + } + if (i < _steps.Count - 1) + { + var downBtn = CreateMiniButton("\uE70D", secondaryText, hoverBg); + downBtn.ToolTip = "아래로 이동"; + downBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx + 1); e.Handled = true; }; + editBtnPanel.Children.Add(downBtn); + } + + var editBtn = CreateMiniButton("\uE70F", accentBrush, hoverBg); + editBtn.ToolTip = "편집"; + editBtn.MouseLeftButtonUp += (_, e) => { EditStep(capturedIdx); e.Handled = true; }; + editBtnPanel.Children.Add(editBtn); + + var delBtn = CreateMiniButton("\uE74D", new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), hoverBg); + delBtn.ToolTip = "삭제"; + delBtn.MouseLeftButtonUp += (_, e) => + { + if (_steps.Count > 1) + { + _steps.RemoveAt(capturedIdx); + _expandedSteps.Remove(capturedIdx); + RenderSteps(); + } + e.Handled = true; + }; + editBtnPanel.Children.Add(delBtn); + + Grid.SetColumn(editBtnPanel, editCol); + cardGrid.Children.Add(editBtnPanel); + } + + card.Child = cardGrid; + _stepsPanel.Children.Add(card); + } + + // ── 단계 추가 버튼 (편집 모드) ── + if (canEdit) + { + var st2 = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hb2 = Application.Current.TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var addBtn = new Border + { + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 8, 14, 8), + Margin = new Thickness(0, 4, 0, 0), + Background = Brushes.Transparent, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x80)), + BorderThickness = new Thickness(1), + Cursor = Cursors.Hand, + }; + var addSp = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center }; + addSp.Children.Add(new TextBlock + { + Text = "\uE710", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = st2, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + addSp.Children.Add(new TextBlock { Text = "단계 추가", FontSize = 12, Foreground = st2 }); + addBtn.Child = addSp; + addBtn.MouseEnter += (s, _) => ((Border)s).Background = hb2; + addBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + addBtn.MouseLeftButtonUp += (_, _) => + { + _steps.Add("새 단계"); + RenderSteps(); + EditStep(_steps.Count - 1); + }; + _stepsPanel.Children.Add(addBtn); + } + + // 현재 단계로 자동 스크롤 + if (_currentStep >= 0 && _stepsPanel.Children.Count > _currentStep) + { + _stepsPanel.UpdateLayout(); + var target = (FrameworkElement)_stepsPanel.Children[Math.Min(_currentStep, _stepsPanel.Children.Count - 1)]; + target.BringIntoView(); + } + } + + // ════════════════════════════════════════════════════════════ + // 단계 편집 / 교환 + // ════════════════════════════════════════════════════════════ + + private void SwapSteps(int a, int b) + { + if (a < 0 || b < 0 || a >= _steps.Count || b >= _steps.Count) return; + (_steps[a], _steps[b]) = (_steps[b], _steps[a]); + RenderSteps(); + } + + private void EditStep(int index) + { + if (index < 0 || index >= _steps.Count) return; + var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + + if (index >= _stepsPanel.Children.Count) return; + + var editCard = new Border + { + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 5), + Background = itemBg, + BorderBrush = accentBrush, + BorderThickness = new Thickness(1.5), + }; + + var textBox = new TextBox + { + Text = _steps[index], + FontSize = 13, + Background = Brushes.Transparent, + Foreground = primaryText, + CaretBrush = primaryText, + BorderThickness = new Thickness(0), + AcceptsReturn = false, + TextWrapping = TextWrapping.Wrap, + Padding = new Thickness(4), + }; + + var capturedIdx = index; + textBox.KeyDown += (_, e) => + { + if (e.Key == Key.Enter) { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); e.Handled = true; } + if (e.Key == Key.Escape) { RenderSteps(); e.Handled = true; } + }; + textBox.LostFocus += (_, _) => { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); }; + + editCard.Child = textBox; + _stepsPanel.Children[index] = editCard; + textBox.Focus(); + textBox.SelectAll(); + } + + // ════════════════════════════════════════════════════════════ + // 하단 버튼 빌드 + // ════════════════════════════════════════════════════════════ + + private void BuildApprovalButtons() + { + _btnPanel.Children.Clear(); + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + + var approveBtn = CreateActionButton("\uE73E", "승인", accentBrush, Brushes.White, true); + approveBtn.MouseLeftButtonUp += (_, _) => + { + _tcs?.TrySetResult(null); + SwitchToExecutionMode(); + }; + _btnPanel.Children.Add(approveBtn); + + var editBtn = CreateActionButton("\uE70F", "수정 요청", accentBrush, accentBrush, false); + editBtn.MouseLeftButtonUp += (_, _) => ShowEditInput(); + _btnPanel.Children.Add(editBtn); + + var reconfirmBtn = CreateActionButton("\uE72C", "재확인", + Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false); + reconfirmBtn.MouseLeftButtonUp += (_, _) => + _tcs?.TrySetResult("계획을 다시 검토하고 더 구체적으로 수정해주세요."); + _btnPanel.Children.Add(reconfirmBtn); + + var cancelBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + var cancelBtn = CreateActionButton("\uE711", "취소", cancelBrush, cancelBrush, false); + cancelBtn.MouseLeftButtonUp += (_, _) => { _tcs?.TrySetResult("취소"); Hide(); }; + _btnPanel.Children.Add(cancelBtn); + } + + private void BuildExecutionButtons() + { + _btnPanel.Children.Clear(); + var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hideBtn = CreateActionButton("\uE921", "숨기기", secondaryText, + Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false); + hideBtn.MouseLeftButtonUp += (_, _) => Hide(); + _btnPanel.Children.Add(hideBtn); + } + + private void BuildCloseButton() + { + _btnPanel.Children.Clear(); + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var closeBtn = CreateActionButton("\uE73E", "닫기", accentBrush, Brushes.White, true); + closeBtn.MouseLeftButtonUp += (_, _) => Hide(); + _btnPanel.Children.Add(closeBtn); + } + + private void ShowEditInput() + { + var editPanel = new Border + { + Margin = new Thickness(20, 0, 20, 12), + Padding = new Thickness(12, 8, 12, 8), + CornerRadius = new CornerRadius(10), + Background = Application.Current.TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)), + }; + var editStack = new StackPanel(); + editStack.Children.Add(new TextBlock + { + Text = "수정 사항을 입력하세요:", + FontSize = 11.5, + Foreground = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(0, 0, 0, 6), + }); + var textBox = new TextBox + { + MinHeight = 44, + MaxHeight = 120, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + Background = Application.Current.TryFindResource("LauncherBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)), + Foreground = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, + CaretBrush = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, + BorderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 8, 10, 8), + }; + editStack.Children.Add(textBox); + + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var sendBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(14, 6, 14, 6), + Margin = new Thickness(0, 8, 0, 0), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + Child = new TextBlock + { + Text = "전송", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, + }, + }; + sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; + sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + sendBtn.MouseLeftButtonUp += (_, _) => + { + var feedback = textBox.Text.Trim(); + if (string.IsNullOrEmpty(feedback)) return; + _tcs?.TrySetResult(feedback); + }; + editStack.Children.Add(sendBtn); + editPanel.Child = editStack; + + if (_btnPanel.Parent is Grid parentGrid) + { + for (int i = parentGrid.Children.Count - 1; i >= 0; i--) + { + if (parentGrid.Children[i] is Border b && b.Tag?.ToString() == "EditPanel") + parentGrid.Children.RemoveAt(i); + } + editPanel.Tag = "EditPanel"; + Grid.SetRow(editPanel, 4); // row 4 = 하단 버튼 행 (toolBar 추가로 1 증가) + parentGrid.Children.Add(editPanel); + _btnPanel.Margin = new Thickness(20, 0, 20, 16); + textBox.Focus(); + } + } + + // ════════════════════════════════════════════════════════════ + // 공통 버튼 팩토리 + // ════════════════════════════════════════════════════════════ + + private static Border CreateMiniButton(string icon, Brush fg, Brush hoverBg) + { + var btn = new Border + { + Width = 24, Height = 24, + CornerRadius = new CornerRadius(6), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + Margin = new Thickness(1, 0, 1, 0), + Child = new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, Foreground = fg, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + btn.MouseEnter += (s, _) => ((Border)s).Background = hoverBg; + btn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + return btn; + } + + private static Border CreateActionButton(string icon, string text, Brush borderColor, + Brush textColor, bool filled) + { + var color = ((SolidColorBrush)borderColor).Color; + var btn = new Border + { + CornerRadius = new CornerRadius(12), + Padding = new Thickness(16, 8, 16, 8), + Margin = new Thickness(4, 0, 4, 0), + Cursor = Cursors.Hand, + Background = filled ? borderColor + : new SolidColorBrush(Color.FromArgb(0x18, color.R, color.G, color.B)), + BorderBrush = filled ? Brushes.Transparent + : new SolidColorBrush(Color.FromArgb(0x80, color.R, color.G, color.B)), + BorderThickness = new Thickness(filled ? 0 : 1.2), + }; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = filled ? Brushes.White : textColor, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + sp.Children.Add(new TextBlock + { + Text = text, FontSize = 12.5, FontWeight = FontWeights.SemiBold, + Foreground = filled ? Brushes.White : textColor, + }); + btn.Child = sp; + btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; + btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + return btn; + } +} diff --git a/src/AxCopilot/Views/PlanViewerWindow.cs b/src/AxCopilot/Views/PlanViewerWindow.cs index 373fc02..e15a358 100644 --- a/src/AxCopilot/Views/PlanViewerWindow.cs +++ b/src/AxCopilot/Views/PlanViewerWindow.cs @@ -16,7 +16,7 @@ namespace AxCopilot.Views; /// - 사방 가장자리 드래그 리사이즈 /// - 항목 드래그로 순서 변경 /// -internal sealed class PlanViewerWindow : Window +internal sealed partial class PlanViewerWindow : Window { // ── Win32 Resize ── [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); @@ -321,611 +321,4 @@ internal sealed class PlanViewerWindow : Window public string PlanText => _planText; public List Steps => _steps; - - // ════════════════════════════════════════════════════════════ - // 단계 목록 렌더링 - // ════════════════════════════════════════════════════════════ - - private void RenderSteps() - { - _stepsPanel.Children.Clear(); - - var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); - var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - - var canEdit = !_isExecuting && _currentStep < 0; // 승인 대기 중에만 편집/순서변경 가능 - - for (int i = 0; i < _steps.Count; i++) - { - var step = _steps[i]; - var capturedIdx = i; - var isComplete = i < _currentStep; - var isCurrent = i == _currentStep; - var isPending = i > _currentStep; - var isExpanded = _expandedSteps.Contains(i); - - // ─ 카드 Border ─ - var card = new Border - { - CornerRadius = new CornerRadius(10), - Padding = new Thickness(10, 7, 10, 7), - Margin = new Thickness(0, 0, 0, 5), - Background = isCurrent - ? new SolidColorBrush(Color.FromArgb(0x18, - ((SolidColorBrush)accentBrush).Color.R, - ((SolidColorBrush)accentBrush).Color.G, - ((SolidColorBrush)accentBrush).Color.B)) - : itemBg, - BorderBrush = isCurrent ? accentBrush : Brushes.Transparent, - BorderThickness = new Thickness(isCurrent ? 1.5 : 0), - AllowDrop = canEdit, - }; - - // 열기/닫기 토글 — 텍스트 또는 배경 클릭 - card.Cursor = Cursors.Hand; - card.MouseLeftButtonUp += (s, e) => - { - // 드래그 직후 클릭이 발생하는 경우 무시 - if (e.OriginalSource is Border src && src.Tag?.ToString() == "DragHandle") return; - if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx); - else _expandedSteps.Add(capturedIdx); - RenderSteps(); - }; - - // ─ 카드 Grid: [drag?][badge][*text][chevron][edit?] ─ - var cardGrid = new Grid(); - int badgeCol = canEdit ? 1 : 0; - int textCol = canEdit ? 2 : 1; - int chevCol = canEdit ? 3 : 2; - int editCol = canEdit ? 4 : -1; - - if (canEdit) - cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // drag - cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // badge - cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text - cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // chevron - if (canEdit) - cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // edit btns - - // ── 드래그 핸들 (편집 모드 전용) ── - if (canEdit) - { - var dimColor = Color.FromArgb(0x55, 0x80, 0x80, 0x80); - var dimBrush = new SolidColorBrush(dimColor); - var dragHandle = new Border - { - Tag = "DragHandle", - Width = 20, Cursor = Cursors.SizeAll, - Background = Brushes.Transparent, - Margin = new Thickness(0, 0, 6, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = "\uE8FD", // Sort/Lines 아이콘 (드래그 핸들) - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 11, - Foreground = dimBrush, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - }; - dragHandle.MouseEnter += (s, _) => - ((TextBlock)((Border)s).Child).Foreground = secondaryText; - dragHandle.MouseLeave += (s, _) => - ((TextBlock)((Border)s).Child).Foreground = dimBrush; - - // 드래그 시작 — 마우스 눌림 위치 기록 - dragHandle.PreviewMouseLeftButtonDown += (s, e) => - { - _dragSourceIndex = capturedIdx; - _dragStartPoint = e.GetPosition(_stepsPanel); - e.Handled = true; // 카드 클릭(expand) 이벤트 방지 - }; - // 충분히 움직이면 DragDrop 시작 - dragHandle.PreviewMouseMove += (s, e) => - { - if (_dragSourceIndex < 0 || e.LeftButton != MouseButtonState.Pressed) return; - var cur = e.GetPosition(_stepsPanel); - if (Math.Abs(cur.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance || - Math.Abs(cur.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance) - { - int idx = _dragSourceIndex; - _dragSourceIndex = -1; - DragDrop.DoDragDrop((DependencyObject)s, - new DataObject(DragDataFormat, idx), DragDropEffects.Move); - // DoDragDrop 완료 후 비주얼 정리 - Dispatcher.InvokeAsync(RenderSteps); - } - }; - dragHandle.PreviewMouseLeftButtonUp += (_, _) => _dragSourceIndex = -1; - - Grid.SetColumn(dragHandle, 0); - cardGrid.Children.Add(dragHandle); - - // ── 카드 Drop 이벤트 ── - card.DragOver += (s, e) => - { - if (!e.Data.GetDataPresent(DragDataFormat)) return; - int src = (int)e.Data.GetData(DragDataFormat); - if (src != capturedIdx) - { - ((Border)s).BorderBrush = accentBrush; - ((Border)s).BorderThickness = new Thickness(1.5); - e.Effects = DragDropEffects.Move; - } - else e.Effects = DragDropEffects.None; - e.Handled = true; - }; - card.DragLeave += (s, _) => - { - bool isCurr = _currentStep == capturedIdx; - ((Border)s).BorderBrush = isCurr ? accentBrush : Brushes.Transparent; - ((Border)s).BorderThickness = new Thickness(isCurr ? 1.5 : 0); - }; - card.Drop += (s, e) => - { - if (!e.Data.GetDataPresent(DragDataFormat)) { e.Handled = true; return; } - int srcIdx = (int)e.Data.GetData(DragDataFormat); - int dstIdx = capturedIdx; - if (srcIdx != dstIdx && srcIdx >= 0 && srcIdx < _steps.Count) - { - var item = _steps[srcIdx]; - _steps.RemoveAt(srcIdx); - // srcIdx < dstIdx 이면 제거 후 인덱스가 1 감소 - int insertAt = srcIdx < dstIdx ? dstIdx - 1 : dstIdx; - _steps.Insert(insertAt, item); - _expandedSteps.Clear(); - RenderSteps(); - } - e.Handled = true; - }; - } - - // ── 상태 배지 ── - UIElement badge; - if (isComplete) - { - badge = new TextBlock - { - Text = "\uE73E", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, Foreground = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), - Width = 20, TextAlignment = TextAlignment.Center, - }; - } - else if (isCurrent) - { - badge = new TextBlock - { - Text = "\uE768", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), - Width = 20, TextAlignment = TextAlignment.Center, - }; - } - else - { - badge = new Border - { - Width = 22, Height = 22, CornerRadius = new CornerRadius(11), - Background = new SolidColorBrush(Color.FromArgb(0x25, 0x80, 0x80, 0x80)), - Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = $"{i + 1}", FontSize = 11, Foreground = secondaryText, - FontWeight = FontWeights.SemiBold, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - }; - } - Grid.SetColumn(badge, badgeCol); - cardGrid.Children.Add(badge); - - // ── 단계 텍스트 ── - var textBlock = new TextBlock - { - Text = step, - FontSize = 13, - Foreground = isComplete ? secondaryText : primaryText, - VerticalAlignment = VerticalAlignment.Center, - Opacity = isPending && _isExecuting ? 0.6 : 1.0, - TextDecorations = isComplete ? TextDecorations.Strikethrough : null, - Margin = new Thickness(0, 0, 4, 0), - }; - if (isExpanded) - { - textBlock.TextWrapping = TextWrapping.Wrap; - textBlock.TextTrimming = TextTrimming.None; - } - else - { - textBlock.TextWrapping = TextWrapping.NoWrap; - textBlock.TextTrimming = TextTrimming.CharacterEllipsis; - textBlock.ToolTip = step; // 접힌 상태: 호버 시 전체 텍스트 툴팁 - } - Grid.SetColumn(textBlock, textCol); - cardGrid.Children.Add(textBlock); - - // ── 펼침/접힘 Chevron ── - var chevron = new Border - { - Width = 22, Height = 22, CornerRadius = new CornerRadius(4), - Background = Brushes.Transparent, Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, canEdit ? 4 : 0, 0), - Child = new TextBlock - { - Text = isExpanded ? "\uE70E" : "\uE70D", // ChevronUp / ChevronDown - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 9, - Foreground = new SolidColorBrush(Color.FromArgb(0x70, 0x80, 0x80, 0x80)), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - }; - chevron.MouseEnter += (s, _) => ((Border)s).Background = hoverBg; - chevron.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; - chevron.MouseLeftButtonUp += (_, e) => - { - if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx); - else _expandedSteps.Add(capturedIdx); - RenderSteps(); - e.Handled = true; - }; - Grid.SetColumn(chevron, chevCol); - cardGrid.Children.Add(chevron); - - // ── 편집 버튼 (위/아래/편집/삭제) ── - if (canEdit) - { - var editBtnPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(2, 0, 0, 0), - }; - - if (i > 0) - { - var upBtn = CreateMiniButton("\uE70E", secondaryText, hoverBg); - upBtn.ToolTip = "위로 이동"; - upBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx - 1); e.Handled = true; }; - editBtnPanel.Children.Add(upBtn); - } - if (i < _steps.Count - 1) - { - var downBtn = CreateMiniButton("\uE70D", secondaryText, hoverBg); - downBtn.ToolTip = "아래로 이동"; - downBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx + 1); e.Handled = true; }; - editBtnPanel.Children.Add(downBtn); - } - - var editBtn = CreateMiniButton("\uE70F", accentBrush, hoverBg); - editBtn.ToolTip = "편집"; - editBtn.MouseLeftButtonUp += (_, e) => { EditStep(capturedIdx); e.Handled = true; }; - editBtnPanel.Children.Add(editBtn); - - var delBtn = CreateMiniButton("\uE74D", new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), hoverBg); - delBtn.ToolTip = "삭제"; - delBtn.MouseLeftButtonUp += (_, e) => - { - if (_steps.Count > 1) - { - _steps.RemoveAt(capturedIdx); - _expandedSteps.Remove(capturedIdx); - RenderSteps(); - } - e.Handled = true; - }; - editBtnPanel.Children.Add(delBtn); - - Grid.SetColumn(editBtnPanel, editCol); - cardGrid.Children.Add(editBtnPanel); - } - - card.Child = cardGrid; - _stepsPanel.Children.Add(card); - } - - // ── 단계 추가 버튼 (편집 모드) ── - if (canEdit) - { - var st2 = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var hb2 = Application.Current.TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - var addBtn = new Border - { - CornerRadius = new CornerRadius(10), - Padding = new Thickness(14, 8, 14, 8), - Margin = new Thickness(0, 4, 0, 0), - Background = Brushes.Transparent, - BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x80)), - BorderThickness = new Thickness(1), - Cursor = Cursors.Hand, - }; - var addSp = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center }; - addSp.Children.Add(new TextBlock - { - Text = "\uE710", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = st2, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), - }); - addSp.Children.Add(new TextBlock { Text = "단계 추가", FontSize = 12, Foreground = st2 }); - addBtn.Child = addSp; - addBtn.MouseEnter += (s, _) => ((Border)s).Background = hb2; - addBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; - addBtn.MouseLeftButtonUp += (_, _) => - { - _steps.Add("새 단계"); - RenderSteps(); - EditStep(_steps.Count - 1); - }; - _stepsPanel.Children.Add(addBtn); - } - - // 현재 단계로 자동 스크롤 - if (_currentStep >= 0 && _stepsPanel.Children.Count > _currentStep) - { - _stepsPanel.UpdateLayout(); - var target = (FrameworkElement)_stepsPanel.Children[Math.Min(_currentStep, _stepsPanel.Children.Count - 1)]; - target.BringIntoView(); - } - } - - // ════════════════════════════════════════════════════════════ - // 단계 편집 / 교환 - // ════════════════════════════════════════════════════════════ - - private void SwapSteps(int a, int b) - { - if (a < 0 || b < 0 || a >= _steps.Count || b >= _steps.Count) return; - (_steps[a], _steps[b]) = (_steps[b], _steps[a]); - RenderSteps(); - } - - private void EditStep(int index) - { - if (index < 0 || index >= _steps.Count) return; - var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - - if (index >= _stepsPanel.Children.Count) return; - - var editCard = new Border - { - CornerRadius = new CornerRadius(10), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 5), - Background = itemBg, - BorderBrush = accentBrush, - BorderThickness = new Thickness(1.5), - }; - - var textBox = new TextBox - { - Text = _steps[index], - FontSize = 13, - Background = Brushes.Transparent, - Foreground = primaryText, - CaretBrush = primaryText, - BorderThickness = new Thickness(0), - AcceptsReturn = false, - TextWrapping = TextWrapping.Wrap, - Padding = new Thickness(4), - }; - - var capturedIdx = index; - textBox.KeyDown += (_, e) => - { - if (e.Key == Key.Enter) { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); e.Handled = true; } - if (e.Key == Key.Escape) { RenderSteps(); e.Handled = true; } - }; - textBox.LostFocus += (_, _) => { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); }; - - editCard.Child = textBox; - _stepsPanel.Children[index] = editCard; - textBox.Focus(); - textBox.SelectAll(); - } - - // ════════════════════════════════════════════════════════════ - // 하단 버튼 빌드 - // ════════════════════════════════════════════════════════════ - - private void BuildApprovalButtons() - { - _btnPanel.Children.Clear(); - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - - var approveBtn = CreateActionButton("\uE73E", "승인", accentBrush, Brushes.White, true); - approveBtn.MouseLeftButtonUp += (_, _) => - { - _tcs?.TrySetResult(null); - SwitchToExecutionMode(); - }; - _btnPanel.Children.Add(approveBtn); - - var editBtn = CreateActionButton("\uE70F", "수정 요청", accentBrush, accentBrush, false); - editBtn.MouseLeftButtonUp += (_, _) => ShowEditInput(); - _btnPanel.Children.Add(editBtn); - - var reconfirmBtn = CreateActionButton("\uE72C", "재확인", - Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false); - reconfirmBtn.MouseLeftButtonUp += (_, _) => - _tcs?.TrySetResult("계획을 다시 검토하고 더 구체적으로 수정해주세요."); - _btnPanel.Children.Add(reconfirmBtn); - - var cancelBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); - var cancelBtn = CreateActionButton("\uE711", "취소", cancelBrush, cancelBrush, false); - cancelBtn.MouseLeftButtonUp += (_, _) => { _tcs?.TrySetResult("취소"); Hide(); }; - _btnPanel.Children.Add(cancelBtn); - } - - private void BuildExecutionButtons() - { - _btnPanel.Children.Clear(); - var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var hideBtn = CreateActionButton("\uE921", "숨기기", secondaryText, - Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false); - hideBtn.MouseLeftButtonUp += (_, _) => Hide(); - _btnPanel.Children.Add(hideBtn); - } - - private void BuildCloseButton() - { - _btnPanel.Children.Clear(); - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - var closeBtn = CreateActionButton("\uE73E", "닫기", accentBrush, Brushes.White, true); - closeBtn.MouseLeftButtonUp += (_, _) => Hide(); - _btnPanel.Children.Add(closeBtn); - } - - private void ShowEditInput() - { - var editPanel = new Border - { - Margin = new Thickness(20, 0, 20, 12), - Padding = new Thickness(12, 8, 12, 8), - CornerRadius = new CornerRadius(10), - Background = Application.Current.TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)), - }; - var editStack = new StackPanel(); - editStack.Children.Add(new TextBlock - { - Text = "수정 사항을 입력하세요:", - FontSize = 11.5, - Foreground = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Margin = new Thickness(0, 0, 0, 6), - }); - var textBox = new TextBox - { - MinHeight = 44, - MaxHeight = 120, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - FontSize = 13, - Background = Application.Current.TryFindResource("LauncherBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)), - Foreground = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, - CaretBrush = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, - BorderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 8, 10, 8), - }; - editStack.Children.Add(textBox); - - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - var sendBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(14, 6, 14, 6), - Margin = new Thickness(0, 8, 0, 0), - Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, - Child = new TextBlock - { - Text = "전송", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, - }, - }; - sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; - sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; - sendBtn.MouseLeftButtonUp += (_, _) => - { - var feedback = textBox.Text.Trim(); - if (string.IsNullOrEmpty(feedback)) return; - _tcs?.TrySetResult(feedback); - }; - editStack.Children.Add(sendBtn); - editPanel.Child = editStack; - - if (_btnPanel.Parent is Grid parentGrid) - { - for (int i = parentGrid.Children.Count - 1; i >= 0; i--) - { - if (parentGrid.Children[i] is Border b && b.Tag?.ToString() == "EditPanel") - parentGrid.Children.RemoveAt(i); - } - editPanel.Tag = "EditPanel"; - Grid.SetRow(editPanel, 4); // row 4 = 하단 버튼 행 (toolBar 추가로 1 증가) - parentGrid.Children.Add(editPanel); - _btnPanel.Margin = new Thickness(20, 0, 20, 16); - textBox.Focus(); - } - } - - // ════════════════════════════════════════════════════════════ - // 공통 버튼 팩토리 - // ════════════════════════════════════════════════════════════ - - private static Border CreateMiniButton(string icon, Brush fg, Brush hoverBg) - { - var btn = new Border - { - Width = 24, Height = 24, - CornerRadius = new CornerRadius(6), - Background = Brushes.Transparent, - Cursor = Cursors.Hand, - Margin = new Thickness(1, 0, 1, 0), - Child = new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, Foreground = fg, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - }; - btn.MouseEnter += (s, _) => ((Border)s).Background = hoverBg; - btn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; - return btn; - } - - private static Border CreateActionButton(string icon, string text, Brush borderColor, - Brush textColor, bool filled) - { - var color = ((SolidColorBrush)borderColor).Color; - var btn = new Border - { - CornerRadius = new CornerRadius(12), - Padding = new Thickness(16, 8, 16, 8), - Margin = new Thickness(4, 0, 4, 0), - Cursor = Cursors.Hand, - Background = filled ? borderColor - : new SolidColorBrush(Color.FromArgb(0x18, color.R, color.G, color.B)), - BorderBrush = filled ? Brushes.Transparent - : new SolidColorBrush(Color.FromArgb(0x80, color.R, color.G, color.B)), - BorderThickness = new Thickness(filled ? 0 : 1.2), - }; - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = filled ? Brushes.White : textColor, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), - }); - sp.Children.Add(new TextBlock - { - Text = text, FontSize = 12.5, FontWeight = FontWeights.SemiBold, - Foreground = filled ? Brushes.White : textColor, - }); - btn.Child = sp; - btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; - btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; - return btn; - } } diff --git a/src/AxCopilot/Views/SettingsWindow.DevMode.cs b/src/AxCopilot/Views/SettingsWindow.DevMode.cs new file mode 100644 index 0000000..47f6ca7 --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.DevMode.cs @@ -0,0 +1,97 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class SettingsWindow +{ + private void DevModeCheckBox_Checked(object sender, RoutedEventArgs e) + { + if (sender is not CheckBox cb || !cb.IsChecked.GetValueOrDefault()) return; + // 설정 창 로드 중 바인딩에 의한 자동 Checked 이벤트 무시 (이미 활성화된 상태 복원) + if (!IsLoaded) return; + + // 테마 리소스 조회 + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60)); + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40)); + + // 비밀번호 확인 다이얼로그 + var dlg = new Window + { + Title = "개발자 모드 — 비밀번호 확인", + Width = 340, SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, ResizeMode = ResizeMode.NoResize, + WindowStyle = WindowStyle.None, AllowsTransparency = true, + Background = Brushes.Transparent, + }; + var border = new Border + { + Background = bgBrush, CornerRadius = new CornerRadius(12), + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(20), + }; + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = "\U0001f512 개발자 모드 활성화", + FontSize = 15, FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12), + }); + stack.Children.Add(new TextBlock + { + Text = "비밀번호를 입력하세요:", + FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6), + }); + var pwBox = new PasswordBox + { + FontSize = 14, Padding = new Thickness(8, 6, 8, 6), + Background = itemBg, Foreground = fgBrush, BorderBrush = borderBrush, PasswordChar = '*', + }; + stack.Children.Add(pwBox); + + var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) }; + var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) }; + cancelBtn.Click += (_, _) => { dlg.DialogResult = false; }; + btnRow.Children.Add(cancelBtn); + var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true }; + okBtn.Click += (_, _) => + { + if (pwBox.Password == "mouse12#") + dlg.DialogResult = true; + else + { + pwBox.Clear(); + pwBox.Focus(); + } + }; + btnRow.Children.Add(okBtn); + stack.Children.Add(btnRow); + border.Child = stack; + dlg.Content = border; + dlg.Loaded += (_, _) => pwBox.Focus(); + + if (dlg.ShowDialog() != true) + { + // 비밀번호 실패/취소 — 체크 해제 + DevMode 강제 false + _vm.DevMode = false; + cb.IsChecked = false; + } + UpdateDevModeContentVisibility(); + } + + private void DevModeCheckBox_Unchecked(object sender, RoutedEventArgs e) + { + UpdateDevModeContentVisibility(); + } + + /// 개발자 모드 활성화 상태에 따라 개발자 탭 내용 표시/숨김. + private void UpdateDevModeContentVisibility() + { + if (DevModeContent != null) + DevModeContent.Visibility = _vm.DevMode ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs b/src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs new file mode 100644 index 0000000..59ac3ab --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs @@ -0,0 +1,139 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class SettingsWindow +{ + // ─── 버전 표시 ────────────────────────────────────────────────────────── + + /// + /// 하단 버전 텍스트를 AxCopilot.csproj <Version> 값에서 동적으로 읽어 설정합니다. + /// 버전을 올릴 때는 AxCopilot.csproj → <Version> 하나만 수정하면 됩니다. + /// 이 함수와 SettingsWindow.xaml 의 VersionInfoText 는 항상 함께 유지됩니다. + /// + private void SetVersionText() + { + try + { + var asm = System.Reflection.Assembly.GetExecutingAssembly(); + // FileVersionInfo 에서 읽어야 csproj 이 반영됩니다. + var fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(asm.Location); + var ver = fvi.ProductVersion ?? fvi.FileVersion ?? "?"; + // 빌드 메타데이터 제거 (예: "1.0.3+gitabcdef" → "1.0.3") + var plusIdx = ver.IndexOf('+'); + if (plusIdx > 0) ver = ver[..plusIdx]; + VersionInfoText.Text = $"AX Copilot · v{ver}"; + } + catch (Exception) + { + VersionInfoText.Text = "AX Copilot"; + } + } + + // ─── 핫키 (콤보박스 선택 방식) ────────────────────────────────────────── + + /// 이전 녹화기에서 호출되던 초기화 — 콤보박스 전환 후 무연산 (호환용) + private void RefreshHotkeyBadges() { /* 콤보박스 SelectedValue 바인딩으로 대체 */ } + + /// 현재 핫키가 콤보박스 목록에 없으면 항목으로 추가합니다. + private void EnsureHotkeyInCombo() + { + if (HotkeyCombo == null) return; + var hotkey = _vm.Hotkey; + if (string.IsNullOrWhiteSpace(hotkey)) return; + + // 이미 목록에 있는지 확인 + foreach (System.Windows.Controls.ComboBoxItem item in HotkeyCombo.Items) + { + if (item.Tag is string tag && tag == hotkey) return; + } + + // 목록에 없으면 현재 값을 추가 + var display = hotkey.Replace("+", " + "); + var newItem = new System.Windows.Controls.ComboBoxItem + { + Content = $"{display} (사용자 정의)", + Tag = hotkey + }; + HotkeyCombo.Items.Insert(0, newItem); + HotkeyCombo.SelectedIndex = 0; + } + + /// Window-level PreviewKeyDown — 핫키 녹화 제거 후 잔여 호출 보호 + private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { } + + /// WPF Key → HotkeyParser가 인식하는 문자열 이름. + private static string GetKeyName(Key key) => key switch + { + Key.Space => "Space", + Key.Enter or Key.Return => "Enter", + Key.Tab => "Tab", + Key.Back => "Backspace", + Key.Delete => "Delete", + Key.Escape => "Escape", + Key.Home => "Home", + Key.End => "End", + Key.PageUp => "PageUp", + Key.PageDown => "PageDown", + Key.Left => "Left", + Key.Right => "Right", + Key.Up => "Up", + Key.Down => "Down", + Key.Insert => "Insert", + // A–Z + >= Key.A and <= Key.Z => key.ToString(), + // 0–9 (메인 키보드) + >= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(), + // F1–F12 + >= Key.F1 and <= Key.F12 => key.ToString(), + // 기호 + Key.OemTilde => "`", + Key.OemMinus => "-", + Key.OemPlus => "=", + Key.OemOpenBrackets => "[", + Key.OemCloseBrackets => "]", + Key.OemPipe or Key.OemBackslash => "\\", + Key.OemSemicolon => ";", + Key.OemQuotes => "'", + Key.OemComma => ",", + Key.OemPeriod => ".", + Key.OemQuestion => "/", + _ => key.ToString() + }; + + private void HotkeyCombo_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + // 콤보박스 선택이 바뀌면 ViewModel의 Hotkey를 업데이트 + // (바인딩이 SelectedValue에 연결되어 자동 처리되지만, + // 기존 RefreshHotkeyBadges 호출은 콤보박스 도입으로 불필요) + } + + // ─── 기존 이벤트 핸들러 ────────────────────────────────────────────────── + + private async void BtnTestConnection_Click(object sender, RoutedEventArgs e) + { + var btn = sender as Button; + if (btn != null) btn.Content = "테스트 중..."; + try + { + // 현재 UI 값으로 임시 LLM 서비스 생성하여 테스트 (설정 저장/창 닫기 없이) + var llm = new Services.LlmService(_vm.Service); + var (ok, msg) = await llm.TestConnectionAsync(); + llm.Dispose(); + CustomMessageBox.Show(msg, ok ? "연결 성공" : "연결 실패", + MessageBoxButton.OK, + ok ? MessageBoxImage.Information : MessageBoxImage.Warning); + } + catch (Exception ex) + { + CustomMessageBox.Show(ex.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn != null) btn.Content = "테스트"; + } + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.Storage.cs b/src/AxCopilot/Views/SettingsWindow.Storage.cs new file mode 100644 index 0000000..7b04b4c --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.Storage.cs @@ -0,0 +1,181 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Input; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class SettingsWindow +{ + // ─── 저장 공간 관리 ────────────────────────────────────────────────────── + + private void RefreshStorageInfo() + { + if (StorageSummaryText == null) return; + var report = StorageAnalyzer.Analyze(); + + StorageSummaryText.Text = $"앱 전체 사용량: {StorageAnalyzer.FormatSize(report.TotalAppUsage)}"; + StorageDriveText.Text = $"드라이브 {report.DriveLabel} 여유: {StorageAnalyzer.FormatSize(report.DriveFreeSpace)} / {StorageAnalyzer.FormatSize(report.DriveTotalSpace)}"; + + if (StorageDetailPanel == null) return; + StorageDetailPanel.Children.Clear(); + + var items = new (string Label, long Size)[] + { + ("대화 기록", report.Conversations), + ("감사 로그", report.AuditLogs), + ("앱 로그", report.Logs), + ("코드 인덱스", report.CodeIndex), + ("임베딩 DB", report.EmbeddingDb), + ("클립보드 히스토리", report.ClipboardHistory), + ("플러그인", report.Plugins), + ("JSON 스킬", report.Skills), + }; + + foreach (var (label, size) in items) + { + if (size == 0) continue; + var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var labelTb = new TextBlock { Text = label, FontSize = 12, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(labelTb, 0); + row.Children.Add(labelTb); + + var sizeTb = new TextBlock { Text = StorageAnalyzer.FormatSize(size), FontSize = 12, FontFamily = ThemeResourceHelper.Consolas, Foreground = Brushes.Gray, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(sizeTb, 1); + row.Children.Add(sizeTb); + + StorageDetailPanel.Children.Add(row); + } + } + + private void BtnStorageRefresh_Click(object sender, RoutedEventArgs e) => RefreshStorageInfo(); + + private void RefreshStorageInfo2() + { + if (StorageSummaryText2 == null) return; + var report = StorageAnalyzer.Analyze(); + StorageSummaryText2.Text = $"앱 전체 사용량: {StorageAnalyzer.FormatSize(report.TotalAppUsage)}"; + StorageDriveText2.Text = $"드라이브 {report.DriveLabel} 여유: {StorageAnalyzer.FormatSize(report.DriveFreeSpace)} / {StorageAnalyzer.FormatSize(report.DriveTotalSpace)}"; + + if (StorageDetailPanel2 == null) return; + StorageDetailPanel2.Children.Clear(); + var items = new (string Label, long Size)[] + { + ("대화 기록", report.Conversations), ("감사 로그", report.AuditLogs), + ("앱 로그", report.Logs), ("코드 인덱스", report.CodeIndex), + ("임베딩 DB", report.EmbeddingDb), + ("클립보드 히스토리", report.ClipboardHistory), + ("플러그인", report.Plugins), ("JSON 스킬", report.Skills), + }; + foreach (var (label, size) in items) + { + if (size == 0) continue; + var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var labelTb = new TextBlock { Text = label, FontSize = 12, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black }; + Grid.SetColumn(labelTb, 0); row.Children.Add(labelTb); + var sizeTb = new TextBlock { Text = StorageAnalyzer.FormatSize(size), FontSize = 12, FontFamily = ThemeResourceHelper.Consolas, Foreground = Brushes.Gray }; + Grid.SetColumn(sizeTb, 1); row.Children.Add(sizeTb); + StorageDetailPanel2.Children.Add(row); + } + } + + private void BtnStorageRefresh2_Click(object sender, RoutedEventArgs e) => RefreshStorageInfo2(); + + private void BtnStorageCleanup_Click(object sender, RoutedEventArgs e) + { + // 테마 리소스 조회 + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)); + var shadowColor = TryFindResource("ShadowColor") is Color sc ? sc : Colors.Black; + + // 보관 기간 선택 팝업 — 커스텀 버튼으로 날짜 선택 + var popup = new Window + { + WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = Brushes.Transparent, + Width = 360, SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, ShowInTaskbar = false, Topmost = true, + }; + + int selectedDays = -1; + + var outerBorder = new Border + { + Background = bgBrush, CornerRadius = new CornerRadius(14), BorderBrush = borderBrush, + BorderThickness = new Thickness(1), Margin = new Thickness(16), + Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = shadowColor }, + }; + + var stack = new StackPanel { Margin = new Thickness(24, 20, 24, 20) }; + stack.Children.Add(new TextBlock { Text = "보관 기간 선택", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 6) }); + stack.Children.Add(new TextBlock { Text = "선택한 기간 이전의 데이터를 삭제합니다.\n※ 통계/대화 기록은 삭제되지 않습니다.", FontSize = 12, Foreground = subFgBrush, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) }); + + var btnDays = new (int Days, string Label)[] { (7, "최근 7일만 보관"), (14, "최근 14일만 보관"), (30, "최근 30일만 보관"), (0, "전체 삭제") }; + foreach (var (days, label) in btnDays) + { + var d = days; + var isDelete = d == 0; + var deleteBg = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0x44, 0x44)); + var deleteBorder = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x44, 0x44)); + var deleteText = new SolidColorBrush(Color.FromRgb(0xFF, 0x66, 0x66)); + var btn = new Border + { + CornerRadius = new CornerRadius(10), Cursor = Cursors.Hand, + Padding = new Thickness(14, 10, 14, 10), Margin = new Thickness(0, 0, 0, 6), + Background = isDelete ? deleteBg : itemBg, + BorderBrush = isDelete ? deleteBorder : borderBrush, + BorderThickness = new Thickness(1), + }; + btn.Child = new TextBlock { Text = label, FontSize = 13, Foreground = isDelete ? deleteText : fgBrush }; + var normalBg = isDelete ? deleteBg : itemBg; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = normalBg; }; + btn.MouseLeftButtonUp += (_, _) => { selectedDays = d; popup.Close(); }; + stack.Children.Add(btn); + } + + // 취소 + var cancelBtn = new Border { CornerRadius = new CornerRadius(10), Cursor = Cursors.Hand, Padding = new Thickness(14, 8, 14, 8), Margin = new Thickness(0, 6, 0, 0), Background = Brushes.Transparent }; + cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = subFgBrush, HorizontalAlignment = HorizontalAlignment.Center }; + cancelBtn.MouseLeftButtonUp += (_, _) => popup.Close(); + stack.Children.Add(cancelBtn); + + outerBorder.Child = stack; + popup.Content = outerBorder; + popup.ShowDialog(); + + if (selectedDays < 0) return; + + // 삭제 전 확인 + var confirmMsg = selectedDays == 0 + ? "전체 데이터를 삭제합니다. 정말 진행하시겠습니까?" + : $"최근 {selectedDays}일 이전의 데이터를 삭제합니다. 정말 진행하시겠습니까?"; + var confirm = CustomMessageBox.Show(confirmMsg, "삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (confirm != MessageBoxResult.Yes) return; + + var freed = StorageAnalyzer.Cleanup( + retainDays: selectedDays, + cleanConversations: false, + cleanAuditLogs: true, + cleanLogs: true, + cleanCodeIndex: true, + cleanClipboard: selectedDays == 0 + ); + + CustomMessageBox.Show( + $"{StorageAnalyzer.FormatSize(freed)}를 확보했습니다.", + "정리 완료", MessageBoxButton.OK, MessageBoxImage.Information); + + RefreshStorageInfo(); + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.UI.cs b/src/AxCopilot/Views/SettingsWindow.UI.cs index d9536f1..96a735d 100644 --- a/src/AxCopilot/Views/SettingsWindow.UI.cs +++ b/src/AxCopilot/Views/SettingsWindow.UI.cs @@ -314,177 +314,6 @@ public partial class SettingsWindow CurrentApp?.RefreshDockBar(); } - // ─── 저장 공간 관리 ────────────────────────────────────────────────────── - - private void RefreshStorageInfo() - { - if (StorageSummaryText == null) return; - var report = StorageAnalyzer.Analyze(); - - StorageSummaryText.Text = $"앱 전체 사용량: {StorageAnalyzer.FormatSize(report.TotalAppUsage)}"; - StorageDriveText.Text = $"드라이브 {report.DriveLabel} 여유: {StorageAnalyzer.FormatSize(report.DriveFreeSpace)} / {StorageAnalyzer.FormatSize(report.DriveTotalSpace)}"; - - if (StorageDetailPanel == null) return; - StorageDetailPanel.Children.Clear(); - - var items = new (string Label, long Size)[] - { - ("대화 기록", report.Conversations), - ("감사 로그", report.AuditLogs), - ("앱 로그", report.Logs), - ("코드 인덱스", report.CodeIndex), - ("임베딩 DB", report.EmbeddingDb), - ("클립보드 히스토리", report.ClipboardHistory), - ("플러그인", report.Plugins), - ("JSON 스킬", report.Skills), - }; - - foreach (var (label, size) in items) - { - if (size == 0) continue; - var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var labelTb = new TextBlock { Text = label, FontSize = 12, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, VerticalAlignment = VerticalAlignment.Center }; - Grid.SetColumn(labelTb, 0); - row.Children.Add(labelTb); - - var sizeTb = new TextBlock { Text = StorageAnalyzer.FormatSize(size), FontSize = 12, FontFamily = ThemeResourceHelper.Consolas, Foreground = Brushes.Gray, VerticalAlignment = VerticalAlignment.Center }; - Grid.SetColumn(sizeTb, 1); - row.Children.Add(sizeTb); - - StorageDetailPanel.Children.Add(row); - } - } - - private void BtnStorageRefresh_Click(object sender, RoutedEventArgs e) => RefreshStorageInfo(); - - private void RefreshStorageInfo2() - { - if (StorageSummaryText2 == null) return; - var report = StorageAnalyzer.Analyze(); - StorageSummaryText2.Text = $"앱 전체 사용량: {StorageAnalyzer.FormatSize(report.TotalAppUsage)}"; - StorageDriveText2.Text = $"드라이브 {report.DriveLabel} 여유: {StorageAnalyzer.FormatSize(report.DriveFreeSpace)} / {StorageAnalyzer.FormatSize(report.DriveTotalSpace)}"; - - if (StorageDetailPanel2 == null) return; - StorageDetailPanel2.Children.Clear(); - var items = new (string Label, long Size)[] - { - ("대화 기록", report.Conversations), ("감사 로그", report.AuditLogs), - ("앱 로그", report.Logs), ("코드 인덱스", report.CodeIndex), - ("임베딩 DB", report.EmbeddingDb), - ("클립보드 히스토리", report.ClipboardHistory), - ("플러그인", report.Plugins), ("JSON 스킬", report.Skills), - }; - foreach (var (label, size) in items) - { - if (size == 0) continue; - var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - var labelTb = new TextBlock { Text = label, FontSize = 12, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black }; - Grid.SetColumn(labelTb, 0); row.Children.Add(labelTb); - var sizeTb = new TextBlock { Text = StorageAnalyzer.FormatSize(size), FontSize = 12, FontFamily = ThemeResourceHelper.Consolas, Foreground = Brushes.Gray }; - Grid.SetColumn(sizeTb, 1); row.Children.Add(sizeTb); - StorageDetailPanel2.Children.Add(row); - } - } - - private void BtnStorageRefresh2_Click(object sender, RoutedEventArgs e) => RefreshStorageInfo2(); - - private void BtnStorageCleanup_Click(object sender, RoutedEventArgs e) - { - // 테마 리소스 조회 - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); - var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); - var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)); - var shadowColor = TryFindResource("ShadowColor") is Color sc ? sc : Colors.Black; - - // 보관 기간 선택 팝업 — 커스텀 버튼으로 날짜 선택 - var popup = new Window - { - WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = Brushes.Transparent, - Width = 360, SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, ShowInTaskbar = false, Topmost = true, - }; - - int selectedDays = -1; - - var outerBorder = new Border - { - Background = bgBrush, CornerRadius = new CornerRadius(14), BorderBrush = borderBrush, - BorderThickness = new Thickness(1), Margin = new Thickness(16), - Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = shadowColor }, - }; - - var stack = new StackPanel { Margin = new Thickness(24, 20, 24, 20) }; - stack.Children.Add(new TextBlock { Text = "보관 기간 선택", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 6) }); - stack.Children.Add(new TextBlock { Text = "선택한 기간 이전의 데이터를 삭제합니다.\n※ 통계/대화 기록은 삭제되지 않습니다.", FontSize = 12, Foreground = subFgBrush, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) }); - - var btnDays = new (int Days, string Label)[] { (7, "최근 7일만 보관"), (14, "최근 14일만 보관"), (30, "최근 30일만 보관"), (0, "전체 삭제") }; - foreach (var (days, label) in btnDays) - { - var d = days; - var isDelete = d == 0; - var deleteBg = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0x44, 0x44)); - var deleteBorder = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x44, 0x44)); - var deleteText = new SolidColorBrush(Color.FromRgb(0xFF, 0x66, 0x66)); - var btn = new Border - { - CornerRadius = new CornerRadius(10), Cursor = Cursors.Hand, - Padding = new Thickness(14, 10, 14, 10), Margin = new Thickness(0, 0, 0, 6), - Background = isDelete ? deleteBg : itemBg, - BorderBrush = isDelete ? deleteBorder : borderBrush, - BorderThickness = new Thickness(1), - }; - btn.Child = new TextBlock { Text = label, FontSize = 13, Foreground = isDelete ? deleteText : fgBrush }; - var normalBg = isDelete ? deleteBg : itemBg; - btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; - btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = normalBg; }; - btn.MouseLeftButtonUp += (_, _) => { selectedDays = d; popup.Close(); }; - stack.Children.Add(btn); - } - - // 취소 - var cancelBtn = new Border { CornerRadius = new CornerRadius(10), Cursor = Cursors.Hand, Padding = new Thickness(14, 8, 14, 8), Margin = new Thickness(0, 6, 0, 0), Background = Brushes.Transparent }; - cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = subFgBrush, HorizontalAlignment = HorizontalAlignment.Center }; - cancelBtn.MouseLeftButtonUp += (_, _) => popup.Close(); - stack.Children.Add(cancelBtn); - - outerBorder.Child = stack; - popup.Content = outerBorder; - popup.ShowDialog(); - - if (selectedDays < 0) return; - - // 삭제 전 확인 - var confirmMsg = selectedDays == 0 - ? "전체 데이터를 삭제합니다. 정말 진행하시겠습니까?" - : $"최근 {selectedDays}일 이전의 데이터를 삭제합니다. 정말 진행하시겠습니까?"; - var confirm = CustomMessageBox.Show(confirmMsg, "삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); - if (confirm != MessageBoxResult.Yes) return; - - var freed = StorageAnalyzer.Cleanup( - retainDays: selectedDays, - cleanConversations: false, - cleanAuditLogs: true, - cleanLogs: true, - cleanCodeIndex: true, - cleanClipboard: selectedDays == 0 - ); - - CustomMessageBox.Show( - $"{StorageAnalyzer.FormatSize(freed)}를 확보했습니다.", - "정리 완료", MessageBoxButton.OK, MessageBoxImage.Information); - - RefreshStorageInfo(); - } - // ─── 알림 카테고리 체크박스 ─────────────────────────────────────────────── private void BuildQuoteCategoryCheckboxes() @@ -543,136 +372,6 @@ public partial class SettingsWindow } } - // ─── 버전 표시 ────────────────────────────────────────────────────────── - - /// - /// 하단 버전 텍스트를 AxCopilot.csproj <Version> 값에서 동적으로 읽어 설정합니다. - /// 버전을 올릴 때는 AxCopilot.csproj → <Version> 하나만 수정하면 됩니다. - /// 이 함수와 SettingsWindow.xaml 의 VersionInfoText 는 항상 함께 유지됩니다. - /// - private void SetVersionText() - { - try - { - var asm = System.Reflection.Assembly.GetExecutingAssembly(); - // FileVersionInfo 에서 읽어야 csproj 이 반영됩니다. - var fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(asm.Location); - var ver = fvi.ProductVersion ?? fvi.FileVersion ?? "?"; - // 빌드 메타데이터 제거 (예: "1.0.3+gitabcdef" → "1.0.3") - var plusIdx = ver.IndexOf('+'); - if (plusIdx > 0) ver = ver[..plusIdx]; - VersionInfoText.Text = $"AX Copilot · v{ver}"; - } - catch (Exception) - { - VersionInfoText.Text = "AX Copilot"; - } - } - - // ─── 핫키 (콤보박스 선택 방식) ────────────────────────────────────────── - - /// 이전 녹화기에서 호출되던 초기화 — 콤보박스 전환 후 무연산 (호환용) - private void RefreshHotkeyBadges() { /* 콤보박스 SelectedValue 바인딩으로 대체 */ } - - /// 현재 핫키가 콤보박스 목록에 없으면 항목으로 추가합니다. - private void EnsureHotkeyInCombo() - { - if (HotkeyCombo == null) return; - var hotkey = _vm.Hotkey; - if (string.IsNullOrWhiteSpace(hotkey)) return; - - // 이미 목록에 있는지 확인 - foreach (System.Windows.Controls.ComboBoxItem item in HotkeyCombo.Items) - { - if (item.Tag is string tag && tag == hotkey) return; - } - - // 목록에 없으면 현재 값을 추가 - var display = hotkey.Replace("+", " + "); - var newItem = new System.Windows.Controls.ComboBoxItem - { - Content = $"{display} (사용자 정의)", - Tag = hotkey - }; - HotkeyCombo.Items.Insert(0, newItem); - HotkeyCombo.SelectedIndex = 0; - } - - /// Window-level PreviewKeyDown — 핫키 녹화 제거 후 잔여 호출 보호 - private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { } - - /// WPF Key → HotkeyParser가 인식하는 문자열 이름. - private static string GetKeyName(Key key) => key switch - { - Key.Space => "Space", - Key.Enter or Key.Return => "Enter", - Key.Tab => "Tab", - Key.Back => "Backspace", - Key.Delete => "Delete", - Key.Escape => "Escape", - Key.Home => "Home", - Key.End => "End", - Key.PageUp => "PageUp", - Key.PageDown => "PageDown", - Key.Left => "Left", - Key.Right => "Right", - Key.Up => "Up", - Key.Down => "Down", - Key.Insert => "Insert", - // A–Z - >= Key.A and <= Key.Z => key.ToString(), - // 0–9 (메인 키보드) - >= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(), - // F1–F12 - >= Key.F1 and <= Key.F12 => key.ToString(), - // 기호 - Key.OemTilde => "`", - Key.OemMinus => "-", - Key.OemPlus => "=", - Key.OemOpenBrackets => "[", - Key.OemCloseBrackets => "]", - Key.OemPipe or Key.OemBackslash => "\\", - Key.OemSemicolon => ";", - Key.OemQuotes => "'", - Key.OemComma => ",", - Key.OemPeriod => ".", - Key.OemQuestion => "/", - _ => key.ToString() - }; - - private void HotkeyCombo_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) - { - // 콤보박스 선택이 바뀌면 ViewModel의 Hotkey를 업데이트 - // (바인딩이 SelectedValue에 연결되어 자동 처리되지만, - // 기존 RefreshHotkeyBadges 호출은 콤보박스 도입으로 불필요) - } - - // ─── 기존 이벤트 핸들러 ────────────────────────────────────────────────── - - private async void BtnTestConnection_Click(object sender, RoutedEventArgs e) - { - var btn = sender as Button; - if (btn != null) btn.Content = "테스트 중..."; - try - { - // 현재 UI 값으로 임시 LLM 서비스 생성하여 테스트 (설정 저장/창 닫기 없이) - var llm = new Services.LlmService(_vm.Service); - var (ok, msg) = await llm.TestConnectionAsync(); - llm.Dispose(); - CustomMessageBox.Show(msg, ok ? "연결 성공" : "연결 실패", - MessageBoxButton.OK, - ok ? MessageBoxImage.Information : MessageBoxImage.Warning); - } - catch (Exception ex) - { - CustomMessageBox.Show(ex.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Error); - } - finally - { - if (btn != null) btn.Content = "테스트"; - } - } - // ─── 기능 설정 서브탭 전환 ────────────────────────────────────────── private void FuncSubTab_Checked(object sender, RoutedEventArgs e) { @@ -710,93 +409,4 @@ public partial class SettingsWindow if (sender is Button btn && btn.Tag is ColorRowModel row) _vm.PickColor(row); } - - private void DevModeCheckBox_Checked(object sender, RoutedEventArgs e) - { - if (sender is not CheckBox cb || !cb.IsChecked.GetValueOrDefault()) return; - // 설정 창 로드 중 바인딩에 의한 자동 Checked 이벤트 무시 (이미 활성화된 상태 복원) - if (!IsLoaded) return; - - // 테마 리소스 조회 - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60)); - var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40)); - - // 비밀번호 확인 다이얼로그 - var dlg = new Window - { - Title = "개발자 모드 — 비밀번호 확인", - Width = 340, SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, ResizeMode = ResizeMode.NoResize, - WindowStyle = WindowStyle.None, AllowsTransparency = true, - Background = Brushes.Transparent, - }; - var border = new Border - { - Background = bgBrush, CornerRadius = new CornerRadius(12), - BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(20), - }; - var stack = new StackPanel(); - stack.Children.Add(new TextBlock - { - Text = "\U0001f512 개발자 모드 활성화", - FontSize = 15, FontWeight = FontWeights.SemiBold, - Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12), - }); - stack.Children.Add(new TextBlock - { - Text = "비밀번호를 입력하세요:", - FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6), - }); - var pwBox = new PasswordBox - { - FontSize = 14, Padding = new Thickness(8, 6, 8, 6), - Background = itemBg, Foreground = fgBrush, BorderBrush = borderBrush, PasswordChar = '*', - }; - stack.Children.Add(pwBox); - - var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) }; - var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) }; - cancelBtn.Click += (_, _) => { dlg.DialogResult = false; }; - btnRow.Children.Add(cancelBtn); - var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true }; - okBtn.Click += (_, _) => - { - if (pwBox.Password == "mouse12#") - dlg.DialogResult = true; - else - { - pwBox.Clear(); - pwBox.Focus(); - } - }; - btnRow.Children.Add(okBtn); - stack.Children.Add(btnRow); - border.Child = stack; - dlg.Content = border; - dlg.Loaded += (_, _) => pwBox.Focus(); - - if (dlg.ShowDialog() != true) - { - // 비밀번호 실패/취소 — 체크 해제 + DevMode 강제 false - _vm.DevMode = false; - cb.IsChecked = false; - } - UpdateDevModeContentVisibility(); - } - - private void DevModeCheckBox_Unchecked(object sender, RoutedEventArgs e) - { - UpdateDevModeContentVisibility(); - } - - /// 개발자 모드 활성화 상태에 따라 개발자 탭 내용 표시/숨김. - private void UpdateDevModeContentVisibility() - { - if (DevModeContent != null) - DevModeContent.Visibility = _vm.DevMode ? Visibility.Visible : Visibility.Collapsed; - } }