[Phase46] 대형 파일 분할 리팩터링 2차 — 19개 신규 파셜 파일 생성

## 분할 대상 및 결과

### 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 20:51:26 +09:00
parent f5a1ba999c
commit aa907d7b79
29 changed files with 5540 additions and 5365 deletions

View File

@@ -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);
}
}

View File

@@ -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));
});
}
/// <summary>ChatWindow 등 외부에서 설정 창을 여는 공개 메서드.</summary>
public void OpenSettingsFromChat() => Dispatcher.Invoke(OpenSettings);
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
private Views.ChatWindow? _chatWindow;
/// <summary>
/// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출).
/// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다.
/// </summary>
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);
}
/// <summary>독 바를 현재 설정으로 즉시 새로고침합니다.</summary>
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();
}
}

View File

@@ -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));
});
}
/// <summary>ChatWindow 등 외부에서 설정 창을 여는 공개 메서드.</summary>
public void OpenSettingsFromChat() => Dispatcher.Invoke(OpenSettings);
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
private Views.ChatWindow? _chatWindow;
/// <summary>
/// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출).
/// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다.
/// </summary>
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);
}
/// <summary>독 바를 현재 설정으로 즉시 새로고침합니다.</summary>
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);
}
}

View File

@@ -0,0 +1,151 @@
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
/// <summary>LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다.</summary>
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 = $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<title>{EscapeHtml(title)}</title>
<style>
{css}
.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}
.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}
.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}
.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}
.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}
.doc p {{ line-height: 1.8; margin-bottom: 12px; }}
.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}
.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}
.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}
.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}
.doc tr:nth-child(even) {{ background: #f8f9fa; }}
</style>
</head>
<body>
<div class=""doc"">
<h1>{EscapeHtml(title)}</h1>
<div class=""meta"">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>
{htmlBody}
</div>
</body>
</html>";
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;
}
}
/// <summary>LLM 텍스트(마크다운 형식)를 HTML로 변환합니다.</summary>
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($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[2..])}</h2>");
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($"<h2>{EscapeHtml(line)}</h2>");
}
else
{
if (!inList) { sb.AppendLine("<ol>"); inList = true; listType = "ol"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
}
continue;
}
// 불릿 리스트
if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• "))
{
var content = line.TrimStart()[2..].Trim();
if (!inList) { sb.AppendLine("<ul>"); inList = true; listType = "ul"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
continue;
}
// 일반 텍스트
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<p>{EscapeHtml(line)}</p>");
}
if (inList) sb.AppendLine($"</{listType}>");
return sb.ToString();
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}

View File

@@ -0,0 +1,349 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
/// <summary>사용자 요청이 문서/보고서 생성인지 판단합니다.</summary>
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));
}
/// <summary>문서 생성 도구인지 확인합니다 (Cowork 검증 대상).</summary>
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";
}
/// <summary>
/// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다.
/// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다.
/// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다.
/// </summary>
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";
}
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
private static bool IsCodeVerificationTarget(string toolName)
{
return toolName is "file_write" or "file_edit" or "script_create"
or "process"; // 빌드/테스트 실행 결과 검증
}
/// <summary>
/// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다.
/// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다.
/// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다.
/// </summary>
/// <summary>읽기 전용 검증 도구 목록 (file_read만 허용)</summary>
private static readonly HashSet<string> VerificationAllowedTools = ["file_read", "directory_list"];
private async Task RunPostToolVerificationAsync(
List<ChatMessage> 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<ChatMessage> { 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<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>();
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<object>();
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<string>? 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);
}
}
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
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; }
}
}

View File

@@ -824,349 +824,6 @@ public partial class AgentLoopService
}
}
/// <summary>LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다.</summary>
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 = $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<title>{EscapeHtml(title)}</title>
<style>
{css}
.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}
.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}
.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}
.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}
.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}
.doc p {{ line-height: 1.8; margin-bottom: 12px; }}
.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}
.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}
.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}
.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}
.doc tr:nth-child(even) {{ background: #f8f9fa; }}
</style>
</head>
<body>
<div class=""doc"">
<h1>{EscapeHtml(title)}</h1>
<div class=""meta"">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>
{htmlBody}
</div>
</body>
</html>";
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;
}
}
/// <summary>LLM 텍스트(마크다운 형식)를 HTML로 변환합니다.</summary>
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($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[2..])}</h2>");
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($"<h2>{EscapeHtml(line)}</h2>");
}
else
{
if (!inList) { sb.AppendLine("<ol>"); inList = true; listType = "ol"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
}
continue;
}
// 불릿 리스트
if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• "))
{
var content = line.TrimStart()[2..].Trim();
if (!inList) { sb.AppendLine("<ul>"); inList = true; listType = "ul"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
continue;
}
// 일반 텍스트
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<p>{EscapeHtml(line)}</p>");
}
if (inList) sb.AppendLine($"</{listType}>");
return sb.ToString();
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>사용자 요청이 문서/보고서 생성인지 판단합니다.</summary>
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));
}
/// <summary>문서 생성 도구인지 확인합니다 (Cowork 검증 대상).</summary>
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";
}
/// <summary>
/// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다.
/// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다.
/// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다.
/// </summary>
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";
}
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
private static bool IsCodeVerificationTarget(string toolName)
{
return toolName is "file_write" or "file_edit" or "script_create"
or "process"; // 빌드/테스트 실행 결과 검증
}
/// <summary>
/// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다.
/// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다.
/// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다.
/// </summary>
/// <summary>읽기 전용 검증 도구 목록 (file_read만 허용)</summary>
private static readonly HashSet<string> VerificationAllowedTools = ["file_read", "directory_list"];
private async Task RunPostToolVerificationAsync(
List<ChatMessage> 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<ChatMessage> { 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<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>();
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<object>();
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<string>? 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);
}
}
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
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; }
}
}

View File

@@ -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
}

View File

@@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent;
/// 테마 무드(현대적, 전문가, 창의적 등)에 따라 CSS 스타일을 제공합니다.
/// HtmlSkill, DocxSkill 등에서 호출하여 문서 생성 시 적용합니다.
/// </summary>
public static class TemplateService
public static partial class TemplateService
{
/// <summary>사용 가능한 테마 무드 목록.</summary>
public static readonly TemplateMood[] AvailableMoods =
@@ -173,561 +173,6 @@ public static class TemplateService
/// <summary>무드 갤러리용 색상 정보.</summary>
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
}
/// <summary>테마 무드 정의.</summary>

View File

@@ -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<List<ContentBlock>> SendClaudeWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
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<object>();
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<object>();
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
};
}
}

View File

@@ -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<List<ContentBlock>> SendGeminiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var contents = new List<object>();
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<object>();
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<object> { 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<string, object>
{
["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;
}
}

View File

@@ -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<List<ContentBlock>> SendOpenAiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
// 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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
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<object>();
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<object>();
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<string, object>
{
["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,
};
}
}

View File

@@ -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<List<ContentBlock>> SendClaudeWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
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<object>();
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<object>();
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<List<ContentBlock>> SendGeminiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var contents = new List<object>();
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<object>();
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<object> { 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<string, object>
{
["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<List<ContentBlock>> SendOpenAiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
// 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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
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<object>();
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<object>();
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<string, object>
{
["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,
};
}
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>

View File

@@ -0,0 +1,417 @@
using System.Collections.ObjectModel;
using AxCopilot.Models;
namespace AxCopilot.ViewModels;
public partial class SettingsViewModel
{
/// <summary>CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근.</summary>
public Models.CodeSettings Code => _service.Settings.Llm.Code;
// ─── 등록 모델 목록 ───────────────────────────────────────────────────
public ObservableCollection<RegisteredModelRow> 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<string> BlockedPaths { get; } = new();
public ObservableCollection<string> 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에 적합한 균형 설정입니다.",
};
}

View File

@@ -5,416 +5,6 @@ namespace AxCopilot.ViewModels;
public partial class SettingsViewModel
{
/// <summary>CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근.</summary>
public Models.CodeSettings Code => _service.Settings.Llm.Code;
// ─── 등록 모델 목록 ───────────────────────────────────────────────────
public ObservableCollection<RegisteredModelRow> 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<string> BlockedPaths { get; } = new();
public ObservableCollection<string> 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

View File

@@ -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
{
// ─── 하단 바 ──────────────────────────────────────────────────────────
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
/// <summary>하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).</summary>
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;
}
/// <summary>Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.</summary>
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;
}
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
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);
}
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
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 자동",
};
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
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"),
};
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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}");
}
}
}

View File

@@ -200,779 +200,4 @@ public partial class ChatWindow
if (_activeTab == "Cowork")
BuildBottomBar();
}
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
/// <summary>하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).</summary>
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;
}
/// <summary>Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.</summary>
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;
}
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
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);
}
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
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 자동",
};
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
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"),
};
}
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
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();
}
}
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
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();
}
/// <summary>Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.</summary>
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;
}
}

View File

@@ -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);
}
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
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<TextBlock>().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;
}
}

View File

@@ -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;
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
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;
}
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
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();
}
}
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
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();
}
/// <summary>Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.</summary>
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;
}
}

View File

@@ -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;
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
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);
}
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> 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);
}
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
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) 연동
// ════════════════════════════════════════════════════════════
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
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;
};
}
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
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);
}
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
private void UpdatePlanViewerStep(AgentEvent evt)
{
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
if (evt.StepCurrent > 0)
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
}
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
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; }
}
}

View File

@@ -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;
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
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);
}
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> 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);
}
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
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) 연동
// ════════════════════════════════════════════════════════════
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
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;
};
}
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
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);
}
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
private void UpdatePlanViewerStep(AgentEvent evt)
{
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
if (evt.StepCurrent > 0)
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
}
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
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);
}
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
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<TextBlock>().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;
}
}

View File

@@ -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;
}
}

View File

@@ -16,7 +16,7 @@ namespace AxCopilot.Views;
/// - 사방 가장자리 드래그 리사이즈
/// - 항목 드래그로 순서 변경
/// </summary>
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<string> 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;
}
}

View File

@@ -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();
}
/// <summary>개발자 모드 활성화 상태에 따라 개발자 탭 내용 표시/숨김.</summary>
private void UpdateDevModeContentVisibility()
{
if (DevModeContent != null)
DevModeContent.Visibility = _vm.DevMode ? Visibility.Visible : Visibility.Collapsed;
}
}

View File

@@ -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
{
// ─── 버전 표시 ──────────────────────────────────────────────────────────
/// <summary>
/// 하단 버전 텍스트를 AxCopilot.csproj &lt;Version&gt; 값에서 동적으로 읽어 설정합니다.
/// 버전을 올릴 때는 AxCopilot.csproj → &lt;Version&gt; 하나만 수정하면 됩니다.
/// 이 함수와 SettingsWindow.xaml 의 VersionInfoText 는 항상 함께 유지됩니다.
/// </summary>
private void SetVersionText()
{
try
{
var asm = System.Reflection.Assembly.GetExecutingAssembly();
// FileVersionInfo 에서 읽어야 csproj <Version> 이 반영됩니다.
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";
}
}
// ─── 핫키 (콤보박스 선택 방식) ──────────────────────────────────────────
/// <summary>이전 녹화기에서 호출되던 초기화 — 콤보박스 전환 후 무연산 (호환용)</summary>
private void RefreshHotkeyBadges() { /* 콤보박스 SelectedValue 바인딩으로 대체 */ }
/// <summary>현재 핫키가 콤보박스 목록에 없으면 항목으로 추가합니다.</summary>
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;
}
/// <summary>Window-level PreviewKeyDown — 핫키 녹화 제거 후 잔여 호출 보호</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { }
/// <summary>WPF Key → HotkeyParser가 인식하는 문자열 이름.</summary>
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",
// AZ
>= Key.A and <= Key.Z => key.ToString(),
// 09 (메인 키보드)
>= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(),
// F1F12
>= 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 = "테스트";
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}
// ─── 버전 표시 ──────────────────────────────────────────────────────────
/// <summary>
/// 하단 버전 텍스트를 AxCopilot.csproj &lt;Version&gt; 값에서 동적으로 읽어 설정합니다.
/// 버전을 올릴 때는 AxCopilot.csproj → &lt;Version&gt; 하나만 수정하면 됩니다.
/// 이 함수와 SettingsWindow.xaml 의 VersionInfoText 는 항상 함께 유지됩니다.
/// </summary>
private void SetVersionText()
{
try
{
var asm = System.Reflection.Assembly.GetExecutingAssembly();
// FileVersionInfo 에서 읽어야 csproj <Version> 이 반영됩니다.
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";
}
}
// ─── 핫키 (콤보박스 선택 방식) ──────────────────────────────────────────
/// <summary>이전 녹화기에서 호출되던 초기화 — 콤보박스 전환 후 무연산 (호환용)</summary>
private void RefreshHotkeyBadges() { /* 콤보박스 SelectedValue 바인딩으로 대체 */ }
/// <summary>현재 핫키가 콤보박스 목록에 없으면 항목으로 추가합니다.</summary>
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;
}
/// <summary>Window-level PreviewKeyDown — 핫키 녹화 제거 후 잔여 호출 보호</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { }
/// <summary>WPF Key → HotkeyParser가 인식하는 문자열 이름.</summary>
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",
// AZ
>= Key.A and <= Key.Z => key.ToString(),
// 09 (메인 키보드)
>= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(),
// F1F12
>= 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();
}
/// <summary>개발자 모드 활성화 상태에 따라 개발자 탭 내용 표시/숨김.</summary>
private void UpdateDevModeContentVisibility()
{
if (DevModeContent != null)
DevModeContent.Visibility = _vm.DevMode ? Visibility.Visible : Visibility.Collapsed;
}
}