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($"{listType}>"); inList = false; }
+ continue;
+ }
+
+ // 마크다운 제목
+ if (line.StartsWith("### "))
+ {
+ if (inList) { sb.AppendLine($"{listType}>"); inList = false; }
+ sb.AppendLine($"{EscapeHtml(line[4..])}
");
+ continue;
+ }
+ if (line.StartsWith("## "))
+ {
+ if (inList) { sb.AppendLine($"{listType}>"); inList = false; }
+ sb.AppendLine($"{EscapeHtml(line[3..])}
");
+ continue;
+ }
+ if (line.StartsWith("# "))
+ {
+ if (inList) { sb.AppendLine($"{listType}>"); 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($"{listType}>"); inList = false; }
+ sb.AppendLine($"{EscapeHtml(line)}
");
+ }
+ else
+ {
+ if (!inList) { sb.AppendLine(""); inList = true; listType = "ol"; }
+ sb.AppendLine($"- {EscapeHtml(content)}
");
+ }
+ 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($"{listType}>"); inList = false; }
+ sb.AppendLine($"{EscapeHtml(line)}
");
+ }
+
+ if (inList) sb.AppendLine($"{listType}>");
+ 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