[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:
92
src/AxCopilot/App.Helpers.cs
Normal file
92
src/AxCopilot/App.Helpers.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
252
src/AxCopilot/App.Settings.cs
Normal file
252
src/AxCopilot/App.Settings.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
151
src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs
Normal file
151
src/AxCopilot/Services/Agent/AgentLoopService.HtmlReport.cs
Normal 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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
349
src/AxCopilot/Services/Agent/AgentLoopService.Verification.cs
Normal file
349
src/AxCopilot/Services/Agent/AgentLoopService.Verification.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
|
||||
559
src/AxCopilot/Services/Agent/TemplateService.Css.cs
Normal file
559
src/AxCopilot/Services/Agent/TemplateService.Css.cs
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
195
src/AxCopilot/Services/LlmService.ClaudeTools.cs
Normal file
195
src/AxCopilot/Services/LlmService.ClaudeTools.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
190
src/AxCopilot/Services/LlmService.GeminiTools.cs
Normal file
190
src/AxCopilot/Services/LlmService.GeminiTools.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
251
src/AxCopilot/Services/LlmService.OpenAiTools.cs
Normal file
251
src/AxCopilot/Services/LlmService.OpenAiTools.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
417
src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs
Normal file
417
src/AxCopilot/ViewModels/SettingsViewModel.LlmProperties.cs
Normal 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에 적합한 균형 설정입니다.",
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
345
src/AxCopilot/Views/ChatWindow.BottomBar.cs
Normal file
345
src/AxCopilot/Views/ChatWindow.BottomBar.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
255
src/AxCopilot/Views/ChatWindow.ConversationMenu.cs
Normal file
255
src/AxCopilot/Views/ChatWindow.ConversationMenu.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
108
src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs
Normal file
108
src/AxCopilot/Views/ChatWindow.ConversationTitleEdit.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
411
src/AxCopilot/Views/ChatWindow.EventBanner.cs
Normal file
411
src/AxCopilot/Views/ChatWindow.EventBanner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
456
src/AxCopilot/Views/ChatWindow.MoodMenu.cs
Normal file
456
src/AxCopilot/Views/ChatWindow.MoodMenu.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
474
src/AxCopilot/Views/ChatWindow.PlanViewer.cs
Normal file
474
src/AxCopilot/Views/ChatWindow.PlanViewer.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
616
src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs
Normal file
616
src/AxCopilot/Views/PlanViewerWindow.StepRenderer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
97
src/AxCopilot/Views/SettingsWindow.DevMode.cs
Normal file
97
src/AxCopilot/Views/SettingsWindow.DevMode.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
139
src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs
Normal file
139
src/AxCopilot/Views/SettingsWindow.HotkeyUI.cs
Normal 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 <Version> 값에서 동적으로 읽어 설정합니다.
|
||||
/// 버전을 올릴 때는 AxCopilot.csproj → <Version> 하나만 수정하면 됩니다.
|
||||
/// 이 함수와 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",
|
||||
// A–Z
|
||||
>= Key.A and <= Key.Z => key.ToString(),
|
||||
// 0–9 (메인 키보드)
|
||||
>= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(),
|
||||
// F1–F12
|
||||
>= Key.F1 and <= Key.F12 => key.ToString(),
|
||||
// 기호
|
||||
Key.OemTilde => "`",
|
||||
Key.OemMinus => "-",
|
||||
Key.OemPlus => "=",
|
||||
Key.OemOpenBrackets => "[",
|
||||
Key.OemCloseBrackets => "]",
|
||||
Key.OemPipe or Key.OemBackslash => "\\",
|
||||
Key.OemSemicolon => ";",
|
||||
Key.OemQuotes => "'",
|
||||
Key.OemComma => ",",
|
||||
Key.OemPeriod => ".",
|
||||
Key.OemQuestion => "/",
|
||||
_ => key.ToString()
|
||||
};
|
||||
|
||||
private void HotkeyCombo_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
// 콤보박스 선택이 바뀌면 ViewModel의 Hotkey를 업데이트
|
||||
// (바인딩이 SelectedValue에 연결되어 자동 처리되지만,
|
||||
// 기존 RefreshHotkeyBadges 호출은 콤보박스 도입으로 불필요)
|
||||
}
|
||||
|
||||
// ─── 기존 이벤트 핸들러 ──────────────────────────────────────────────────
|
||||
|
||||
private async void BtnTestConnection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var btn = sender as Button;
|
||||
if (btn != null) btn.Content = "테스트 중...";
|
||||
try
|
||||
{
|
||||
// 현재 UI 값으로 임시 LLM 서비스 생성하여 테스트 (설정 저장/창 닫기 없이)
|
||||
var llm = new Services.LlmService(_vm.Service);
|
||||
var (ok, msg) = await llm.TestConnectionAsync();
|
||||
llm.Dispose();
|
||||
CustomMessageBox.Show(msg, ok ? "연결 성공" : "연결 실패",
|
||||
MessageBoxButton.OK,
|
||||
ok ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CustomMessageBox.Show(ex.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (btn != null) btn.Content = "테스트";
|
||||
}
|
||||
}
|
||||
}
|
||||
181
src/AxCopilot/Views/SettingsWindow.Storage.cs
Normal file
181
src/AxCopilot/Views/SettingsWindow.Storage.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <Version> 값에서 동적으로 읽어 설정합니다.
|
||||
/// 버전을 올릴 때는 AxCopilot.csproj → <Version> 하나만 수정하면 됩니다.
|
||||
/// 이 함수와 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",
|
||||
// A–Z
|
||||
>= Key.A and <= Key.Z => key.ToString(),
|
||||
// 0–9 (메인 키보드)
|
||||
>= Key.D0 and <= Key.D9 => ((int)(key - Key.D0)).ToString(),
|
||||
// F1–F12
|
||||
>= Key.F1 and <= Key.F12 => key.ToString(),
|
||||
// 기호
|
||||
Key.OemTilde => "`",
|
||||
Key.OemMinus => "-",
|
||||
Key.OemPlus => "=",
|
||||
Key.OemOpenBrackets => "[",
|
||||
Key.OemCloseBrackets => "]",
|
||||
Key.OemPipe or Key.OemBackslash => "\\",
|
||||
Key.OemSemicolon => ";",
|
||||
Key.OemQuotes => "'",
|
||||
Key.OemComma => ",",
|
||||
Key.OemPeriod => ".",
|
||||
Key.OemQuestion => "/",
|
||||
_ => key.ToString()
|
||||
};
|
||||
|
||||
private void HotkeyCombo_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
// 콤보박스 선택이 바뀌면 ViewModel의 Hotkey를 업데이트
|
||||
// (바인딩이 SelectedValue에 연결되어 자동 처리되지만,
|
||||
// 기존 RefreshHotkeyBadges 호출은 콤보박스 도입으로 불필요)
|
||||
}
|
||||
|
||||
// ─── 기존 이벤트 핸들러 ──────────────────────────────────────────────────
|
||||
|
||||
private async void BtnTestConnection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var btn = sender as Button;
|
||||
if (btn != null) btn.Content = "테스트 중...";
|
||||
try
|
||||
{
|
||||
// 현재 UI 값으로 임시 LLM 서비스 생성하여 테스트 (설정 저장/창 닫기 없이)
|
||||
var llm = new Services.LlmService(_vm.Service);
|
||||
var (ok, msg) = await llm.TestConnectionAsync();
|
||||
llm.Dispose();
|
||||
CustomMessageBox.Show(msg, ok ? "연결 성공" : "연결 실패",
|
||||
MessageBoxButton.OK,
|
||||
ok ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CustomMessageBox.Show(ex.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (btn != null) btn.Content = "테스트";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 기능 설정 서브탭 전환 ──────────────────────────────────────────
|
||||
private void FuncSubTab_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -710,93 +409,4 @@ public partial class SettingsWindow
|
||||
if (sender is Button btn && btn.Tag is ColorRowModel row)
|
||||
_vm.PickColor(row);
|
||||
}
|
||||
|
||||
private void DevModeCheckBox_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not CheckBox cb || !cb.IsChecked.GetValueOrDefault()) return;
|
||||
// 설정 창 로드 중 바인딩에 의한 자동 Checked 이벤트 무시 (이미 활성화된 상태 복원)
|
||||
if (!IsLoaded) return;
|
||||
|
||||
// 테마 리소스 조회
|
||||
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||||
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60));
|
||||
var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40));
|
||||
|
||||
// 비밀번호 확인 다이얼로그
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "개발자 모드 — 비밀번호 확인",
|
||||
Width = 340, SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Owner = this, ResizeMode = ResizeMode.NoResize,
|
||||
WindowStyle = WindowStyle.None, AllowsTransparency = true,
|
||||
Background = Brushes.Transparent,
|
||||
};
|
||||
var border = new Border
|
||||
{
|
||||
Background = bgBrush, CornerRadius = new CornerRadius(12),
|
||||
BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(20),
|
||||
};
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\U0001f512 개발자 모드 활성화",
|
||||
FontSize = 15, FontWeight = FontWeights.SemiBold,
|
||||
Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12),
|
||||
});
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "비밀번호를 입력하세요:",
|
||||
FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6),
|
||||
});
|
||||
var pwBox = new PasswordBox
|
||||
{
|
||||
FontSize = 14, Padding = new Thickness(8, 6, 8, 6),
|
||||
Background = itemBg, Foreground = fgBrush, BorderBrush = borderBrush, PasswordChar = '*',
|
||||
};
|
||||
stack.Children.Add(pwBox);
|
||||
|
||||
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
|
||||
var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) };
|
||||
cancelBtn.Click += (_, _) => { dlg.DialogResult = false; };
|
||||
btnRow.Children.Add(cancelBtn);
|
||||
var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true };
|
||||
okBtn.Click += (_, _) =>
|
||||
{
|
||||
if (pwBox.Password == "mouse12#")
|
||||
dlg.DialogResult = true;
|
||||
else
|
||||
{
|
||||
pwBox.Clear();
|
||||
pwBox.Focus();
|
||||
}
|
||||
};
|
||||
btnRow.Children.Add(okBtn);
|
||||
stack.Children.Add(btnRow);
|
||||
border.Child = stack;
|
||||
dlg.Content = border;
|
||||
dlg.Loaded += (_, _) => pwBox.Focus();
|
||||
|
||||
if (dlg.ShowDialog() != true)
|
||||
{
|
||||
// 비밀번호 실패/취소 — 체크 해제 + DevMode 강제 false
|
||||
_vm.DevMode = false;
|
||||
cb.IsChecked = false;
|
||||
}
|
||||
UpdateDevModeContentVisibility();
|
||||
}
|
||||
|
||||
private void DevModeCheckBox_Unchecked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UpdateDevModeContentVisibility();
|
||||
}
|
||||
|
||||
/// <summary>개발자 모드 활성화 상태에 따라 개발자 탭 내용 표시/숨김.</summary>
|
||||
private void UpdateDevModeContentVisibility()
|
||||
{
|
||||
if (DevModeContent != null)
|
||||
DevModeContent.Visibility = _vm.DevMode ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user