diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index dfa601d..81f83b8 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4584,5 +4584,26 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~39 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 7차) +## Phase 40 — ChatWindow 2차 파셜 클래스 분할 (v2.3) ✅ 완료 + +> **목표**: 4,767줄 ChatWindow.xaml.cs (1차 분할 후 잔여)를 7개 파셜 파일로 추가 분할. + +| 파일 | 줄 수 | 내용 | +|------|-------|------| +| `ChatWindow.xaml.cs` (메인) | 262 | 필드, 생성자, OnClosing, ForceClose, ConversationMeta | +| `ChatWindow.Controls.cs` | 595 | 사용자 정보, 스크롤, 제목 편집, 카테고리 드롭다운, 탭 전환 | +| `ChatWindow.WorkFolder.cs` | 359 | 작업 폴더 메뉴, 폴더 설정, 컨텍스트 메뉴 | +| `ChatWindow.PermissionMenu.cs` | 498 | 권한 팝업, 데이터 활용 메뉴, 파일 첨부, 사이드바 토글 | +| `ChatWindow.ConversationList.cs` | 747 | 대화 목록, 그룹 헤더, 제목 편집, 검색, 날짜 포맷 | +| `ChatWindow.Sending.cs` | 720 | 편집 모드, 타이머, SendMessageAsync, BtnSend_Click | +| `ChatWindow.HelpCommands.cs` | 157 | /help 도움말 창, AddHelpSection | +| `ChatWindow.ResponseHandling.cs` | 1,494 | 응답재생성, 스트리밍, 내보내기, 팁, 토스트, 상태바, 키보드 | + +- **메인 파일**: 4,767줄 → 262줄 (**94.5% 감소**) +- **전체 ChatWindow 파셜 파일 수**: 15개 (1차 7개 + 2차 7개 + 메인 1개) +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~40 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 8차) diff --git a/src/AxCopilot/Views/ChatWindow.Controls.cs b/src/AxCopilot/Views/ChatWindow.Controls.cs new file mode 100644 index 0000000..9423c89 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.Controls.cs @@ -0,0 +1,595 @@ +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; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 사용자 정보 ──────────────────────────────────────────────────── + + private void SetupUserInfo() + { + var userName = Environment.UserName; + // AD\, AD/, AD: 접두사 제거 + var cleanName = userName; + foreach (var sep in new[] { '\\', '/', ':' }) + { + var idx = cleanName.LastIndexOf(sep); + if (idx >= 0) cleanName = cleanName[(idx + 1)..]; + } + + var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U"; + var pcName = Environment.MachineName; + + UserInitialSidebar.Text = initial; + UserInitialIconBar.Text = initial; + UserNameText.Text = cleanName; + UserPcText.Text = pcName; + BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})"; + } + + // ─── 스크롤 동작 ────────────────────────────────────────────────── + + private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + // 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥 + if (MessageScroll.ScrollableHeight <= 1) + { + _userScrolled = false; + return; + } + + // 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지 + if (Math.Abs(e.ExtentHeightChange) > 0.5) + return; + + var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40; + _userScrolled = !atBottom; + } + + private void AutoScrollIfNeeded() + { + if (!_userScrolled) + SmoothScrollToEnd(); + } + + /// 새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋). + private void ForceScrollToEnd() + { + _userScrolled = false; + Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background); + } + + /// 부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다. + private void SmoothScrollToEnd() + { + var targetOffset = MessageScroll.ScrollableHeight; + var currentOffset = MessageScroll.VerticalOffset; + var diff = targetOffset - currentOffset; + + // 차이가 작으면 즉시 이동 (깜빡임 방지) + if (diff <= 60) + { + MessageScroll.ScrollToEnd(); + return; + } + + // 부드럽게 스크롤 (DoubleAnimation) + var animation = new DoubleAnimation + { + From = currentOffset, + To = targetOffset, + Duration = TimeSpan.FromMilliseconds(200), + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset); + + // ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간 + var startTime = DateTime.UtcNow; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps + EventHandler tickHandler = null!; + tickHandler = (_, _) => + { + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + var progress = Math.Min(elapsed / 200.0, 1.0); + var eased = 1.0 - Math.Pow(1.0 - progress, 3); + var offset = currentOffset + diff * eased; + MessageScroll.ScrollToVerticalOffset(offset); + + if (progress >= 1.0) + { + timer.Stop(); + timer.Tick -= tickHandler; + } + }; + timer.Tick += tickHandler; + timer.Start(); + } + + // ─── 대화 제목 인라인 편집 ────────────────────────────────────────── + + private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e) + { + lock (_convLock) + { + if (_currentConversation == null) return; + } + ChatTitle.Visibility = Visibility.Collapsed; + ChatTitleEdit.Text = ChatTitle.Text; + ChatTitleEdit.Visibility = Visibility.Visible; + ChatTitleEdit.Focus(); + ChatTitleEdit.SelectAll(); + } + + private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit(); + private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; } + if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; } + } + + private void CommitTitleEdit() + { + var newTitle = ChatTitleEdit.Text.Trim(); + ChatTitleEdit.Visibility = Visibility.Collapsed; + ChatTitle.Visibility = Visibility.Visible; + + if (string.IsNullOrEmpty(newTitle)) return; + + lock (_convLock) + { + if (_currentConversation == null) return; + _currentConversation.Title = newTitle; + } + + ChatTitle.Text = newTitle; + try + { + ChatConversation conv; + lock (_convLock) conv = _currentConversation!; + _storage.Save(conv); + } + catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + RefreshConversationList(); + } + + private void CancelTitleEdit() + { + ChatTitleEdit.Visibility = Visibility.Collapsed; + ChatTitle.Visibility = Visibility.Visible; + } + + // ─── 카테고리 드롭다운 ────────────────────────────────────────────── + + private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e) + { + var borderBrush = ThemeResourceHelper.Border(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var hoverBg = ThemeResourceHelper.HoverBg(this); + var accentBrush = ThemeResourceHelper.Accent(this); + + var (popup, stack) = PopupMenuHelper.Create(BtnCategoryDrop, this, PlacementMode.Bottom, minWidth: 180); + popup.VerticalOffset = 4; + + Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick) + { + var item = 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 g = new Grid(); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); + + var iconTb = new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconTb, 0); + g.Children.Add(iconTb); + + var textTb = new TextBlock + { + Text = text, FontSize = 12.5, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(textTb, 1); + g.Children.Add(textTb); + + if (isSelected) + { + var check = CreateSimpleCheck(accentBrush, 14); + Grid.SetColumn(check, 2); + g.Children.Add(check); + } + + item.Child = g; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); }; + return item; + } + + Border CreateSep() => new() + { + Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4), + }; + + // 전체 보기 + var allLabel = _activeTab switch + { + "Cowork" => "모든 작업", + "Code" => "모든 작업", + _ => "모든 주제", + }; + stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText, + string.IsNullOrEmpty(_selectedCategory), + () => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); })); + + stack.Children.Add(CreateSep()); + + if (_activeTab == "Cowork" || _activeTab == "Code") + { + // 코워크/코드: 프리셋 카테고리 기반 필터 + var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); + var seen = new HashSet(); + foreach (var p in presets) + { + if (p.IsCustom) continue; // 커스텀은 별도 그룹 + if (!seen.Add(p.Category)) continue; + var capturedCat = p.Category; + stack.Children.Add(CreateCatItem(p.Symbol, p.Label, BrushFromHex(p.Color), + _selectedCategory == capturedCat, + () => { _selectedCategory = capturedCat; UpdateCategoryLabel(); RefreshConversationList(); })); + } + // 커스텀 프리셋 통합 필터 + if (presets.Any(p => p.IsCustom)) + { + stack.Children.Add(CreateSep()); + stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, + _selectedCategory == "__custom__", + () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); + } + } + else + { + // Chat: 기존 ChatCategory 기반 + foreach (var (key, label, symbol, color) in ChatCategory.All) + { + var capturedKey = key; + stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color), + _selectedCategory == capturedKey, + () => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); })); + } + // 커스텀 프리셋 통합 필터 (Chat) + var chatCustom = Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList(); + if (chatCustom.Count > 0) + { + stack.Children.Add(CreateSep()); + stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, + _selectedCategory == "__custom__", + () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); + } + } + + popup.IsOpen = true; + } + + private void UpdateCategoryLabel() + { + if (string.IsNullOrEmpty(_selectedCategory)) + { + CategoryLabel.Text = _activeTab switch { "Cowork" or "Code" => "모든 작업", _ => "모든 주제" }; + CategoryIcon.Text = "\uE8BD"; + } + else if (_selectedCategory == "__custom__") + { + CategoryLabel.Text = "커스텀 프리셋"; + CategoryIcon.Text = "\uE710"; + } + else + { + // ChatCategory에서 찾기 + foreach (var (key, label, symbol, _) in ChatCategory.All) + { + if (key == _selectedCategory) + { + CategoryLabel.Text = label; + CategoryIcon.Text = symbol; + return; + } + } + // 프리셋 카테고리에서 찾기 (Cowork/Code) + var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); + var match = presets.FirstOrDefault(p => p.Category == _selectedCategory); + if (match != null) + { + CategoryLabel.Text = match.Label; + CategoryIcon.Text = match.Symbol; + } + else + { + CategoryLabel.Text = _selectedCategory; + CategoryIcon.Text = "\uE8BD"; + } + } + } + + // ─── 창 컨트롤 ────────────────────────────────────────────────────── + + // WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요 + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + var source = System.Windows.Interop.HwndSource.FromHwnd( + new System.Windows.Interop.WindowInteropHelper(this).Handle); + source?.AddHook(WndProc); + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + // WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보 + if (msg == 0x0024) + { + var screen = System.Windows.Forms.Screen.FromHandle(hwnd); + var workArea = screen.WorkingArea; + var monitor = screen.Bounds; + + var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd); + + // MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36) + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y + handled = true; + } + return IntPtr.Zero; + } + + private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; + private void BtnMaximize_Click(object sender, RoutedEventArgs e) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘 + } + private void BtnClose_Click(object sender, RoutedEventArgs e) => Close(); + + // ─── 탭 전환 ────────────────────────────────────────────────────────── + + private string _activeTab = "Chat"; + + private void SaveCurrentTabConversationId() + { + lock (_convLock) + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + { + _tabConversationId[_activeTab] = _currentConversation.Id; + // 탭 전환 시 현재 대화를 즉시 저장 (스트리밍 중이어도 진행 중인 내용 보존) + try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + } + } + // 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용) + SaveLastConversations(); + } + + /// 탭 전환 전 스트리밍 중이면 즉시 중단합니다. + private void StopStreamingIfActive() + { + if (!_isStreaming) return; + // 스트리밍 중단 + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + StopRainbowGlow(); + HideStickyProgress(); + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnPause.Visibility = Visibility.Collapsed; + PauseIcon.Text = "\uE769"; // 리셋 + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + private void TabChat_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Chat") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Chat"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + private void TabCowork_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Cowork") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Cowork"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + private void TabCode_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Code") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Code"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + /// 탭별로 마지막으로 활성화된 대화 ID를 기억. + private readonly Dictionary _tabConversationId = new() + { + ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, + }; + + private void UpdateTabUI() + { + // 폴더 바는 Cowork/Code 탭에서만 표시 + if (FolderBar != null) + FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; + + // 탭별 입력 안내 문구 + if (InputWatermark != null) + { + InputWatermark.Text = _activeTab switch + { + "Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)", + "Code" => "코드 관련 작업을 요청하세요...", + _ => _promptCardPlaceholder, + }; + } + + // 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용) + ApplyTabDefaultPermission(); + + // 포맷/디자인 드롭다운은 Cowork 탭에서만 표시 + if (_activeTab == "Cowork") + { + BuildBottomBar(); + if (Llm.ShowFileBrowser && FileBrowserPanel != null) + { + FileBrowserPanel.Visibility = Visibility.Visible; + BuildFileTree(); + } + } + else if (_activeTab == "Code") + { + // Code 탭: 언어 선택기 + 파일 탐색기 + BuildCodeBottomBar(); + if (Llm.ShowFileBrowser && FileBrowserPanel != null) + { + FileBrowserPanel.Visibility = Visibility.Visible; + BuildFileTree(); + } + } + else + { + MoodIconPanel.Children.Clear(); + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; + } + + // 탭별 프리셋 버튼 재구성 + BuildTopicButtons(); + + // 현재 대화를 해당 탭 대화로 전환 + SwitchToTabConversation(); + + // Cowork/Code 탭 전환 시 팁 표시 + ShowRandomTip(); + } + + private void BtnPlanMode_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // 3단 순환: off → auto → always → off + Llm.PlanMode = Llm.PlanMode switch + { + "auto" => "always", + "always" => "off", + _ => "auto" + }; + _settings.Save(); + UpdatePlanModeUI(); + } + + private void UpdatePlanModeUI() + { + var planMode = Llm.PlanMode ?? "off"; + if (PlanModeValue == null) return; + + PlanModeValue.Text = planMode switch + { + "auto" => "Auto", + "always" => "Always", + _ => "Off" + }; + var isActive = planMode != "off"; + var activeBrush = ThemeResourceHelper.Accent(this); + var secondaryBrush = ThemeResourceHelper.Secondary(this); + if (PlanModeIcon != null) PlanModeIcon.Foreground = isActive ? activeBrush : secondaryBrush; + if (PlanModeLabel != null) PlanModeLabel.Foreground = isActive ? activeBrush : secondaryBrush; + if (BtnPlanMode != null) + BtnPlanMode.Background = isActive + ? new SolidColorBrush(Color.FromArgb(0x1A, 0x4B, 0x5E, 0xFC)) + : Brushes.Transparent; + } + + private void SwitchToTabConversation() + { + // 이전 탭의 대화 저장 + lock (_convLock) + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + { + try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + } + } + + // 현재 탭에 기억된 대화가 있으면 복원 + var savedId = _tabConversationId.GetValueOrDefault(_activeTab); + if (!string.IsNullOrEmpty(savedId)) + { + var conv = _storage.Load(savedId); + if (conv != null) + { + if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; + lock (_convLock) _currentConversation = conv; + MessagePanel.Children.Clear(); + foreach (var msg in conv.Messages) + AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); + EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + return; + } + } + + // 기억된 대화가 없으면 새 대화 + lock (_convLock) + { + _currentConversation = new ChatConversation { Tab = _activeTab }; + var workFolder = Llm.WorkFolder; + if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") + _currentConversation.WorkFolder = workFolder; + } + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ConversationList.cs b/src/AxCopilot/Views/ChatWindow.ConversationList.cs new file mode 100644 index 0000000..4c840e9 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ConversationList.cs @@ -0,0 +1,747 @@ +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 AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 대화 목록 ──────────────────────────────────────────────────────── + + public void RefreshConversationList() + { + var metas = _storage.LoadAllMeta(); + // 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원) + var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", Llm.CustomPresets) + .Concat(Services.PresetService.GetByTabWithCustom("Code", Llm.CustomPresets)) + .Concat(Services.PresetService.GetByTabWithCustom("Chat", Llm.CustomPresets)); + var presetMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in allPresets) + presetMap.TryAdd(p.Category, (p.Symbol, p.Color)); + + var items = metas.Select(c => + { + var symbol = ChatCategory.GetSymbol(c.Category); + var color = ChatCategory.GetColor(c.Category); + // ChatCategory 기본값이면 프리셋에서 검색 + if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General) + { + if (presetMap.TryGetValue(c.Category, out var pm)) + { + symbol = pm.Symbol; + color = pm.Color; + } + } + return new ConversationMeta + { + Id = c.Id, + Title = c.Title, + Pinned = c.Pinned, + Category = c.Category, + Symbol = symbol, + ColorHex = color, + Tab = string.IsNullOrEmpty(c.Tab) ? "Chat" : c.Tab, + UpdatedAtText = FormatDate(c.UpdatedAt), + UpdatedAt = c.UpdatedAt, + Preview = c.Preview ?? "", + ParentId = c.ParentId, + }; + }).ToList(); + + // 탭 필터 — 현재 활성 탭의 대화만 표시 + items = items.Where(i => i.Tab == _activeTab).ToList(); + + // 카테고리 필터 적용 + if (_selectedCategory == "__custom__") + { + // 커스텀 프리셋으로 만든 대화만 표시 + var customCats = Llm.CustomPresets + .Select(c => $"custom_{c.Id}").ToHashSet(); + items = items.Where(i => customCats.Contains(i.Category)).ToList(); + } + else if (!string.IsNullOrEmpty(_selectedCategory)) + items = items.Where(i => i.Category == _selectedCategory).ToList(); + + // 검색 필터 (제목 + 내용 미리보기) + var search = SearchBox?.Text?.Trim() ?? ""; + if (!string.IsNullOrEmpty(search)) + items = items.Where(i => + i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || + i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + RenderConversationList(items); + } + + private const int ConversationPageSize = 50; + private List? _pendingConversations; + + private void RenderConversationList(List items) + { + ConversationPanel.Children.Clear(); + _pendingConversations = null; + + if (items.Count == 0) + { + var empty = new TextBlock + { + Text = "대화가 없습니다", + FontSize = 12, + Foreground = (ThemeResourceHelper.Secondary(this)), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 20, 0, 0) + }; + ConversationPanel.Children.Add(empty); + return; + } + + // 오늘 / 이전 그룹 분리 + var today = DateTime.Today; + var todayItems = items.Where(i => i.UpdatedAt.Date == today).ToList(); + var olderItems = items.Where(i => i.UpdatedAt.Date < today).ToList(); + + var allOrdered = new List<(string Group, ConversationMeta Item)>(); + foreach (var item in todayItems) allOrdered.Add(("오늘", item)); + foreach (var item in olderItems) allOrdered.Add(("이전", item)); + + // 첫 페이지만 렌더링 + var firstPage = allOrdered.Take(ConversationPageSize).ToList(); + string? lastGroup = null; + foreach (var (group, item) in firstPage) + { + if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; } + AddConversationItem(item); + } + + // 나머지가 있으면 "더 보기" 버튼 + if (allOrdered.Count > ConversationPageSize) + { + _pendingConversations = items; + AddLoadMoreButton(allOrdered.Count - ConversationPageSize); + } + } + + private void AddLoadMoreButton(int remaining) + { + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var btn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 10, 8, 10), + Margin = new Thickness(6, 4, 6, 4), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + sp.Children.Add(new TextBlock + { + Text = $"더 보기 ({remaining}개 남음)", + FontSize = 12, + Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, + }); + btn.Child = sp; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + btn.MouseLeftButtonUp += (_, _) => + { + // 전체 목록 렌더링 + if (_pendingConversations != null) + { + var all = _pendingConversations; + _pendingConversations = null; + ConversationPanel.Children.Clear(); + + var today = DateTime.Today; + var todayItems = all.Where(i => i.UpdatedAt.Date == today).ToList(); + var olderItems = all.Where(i => i.UpdatedAt.Date < today).ToList(); + + if (todayItems.Count > 0) + { + AddGroupHeader("오늘"); + foreach (var item in todayItems) AddConversationItem(item); + } + if (olderItems.Count > 0) + { + AddGroupHeader("이전"); + foreach (var item in olderItems) AddConversationItem(item); + } + } + }; + ConversationPanel.Children.Add(btn); + } + + private void AddGroupHeader(string text) + { + var header = new TextBlock + { + Text = text, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = (ThemeResourceHelper.Secondary(this)), + Margin = new Thickness(8, 12, 0, 4) + }; + ConversationPanel.Children.Add(header); + } + + private void AddConversationItem(ConversationMeta item) + { + var isSelected = false; + lock (_convLock) + isSelected = _currentConversation?.Id == item.Id; + + var isBranch = !string.IsNullOrEmpty(item.ParentId); + var border = new Border + { + Background = isSelected + ? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)) + : Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = isBranch ? new Thickness(16, 1, 0, 1) : new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상) + Brush iconBrush; + if (item.Pinned) + iconBrush = Brushes.Orange; + else + { + try { iconBrush = ThemeResourceHelper.HexBrush(item.ColorHex); } + catch (Exception) { iconBrush = ThemeResourceHelper.Accent(this); } + } + var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol; + if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색 + var icon = new TextBlock + { + Text = iconText, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, + Foreground = iconBrush, + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(icon, 0); + grid.Children.Add(icon); + + // 제목 + 날짜 (선택 시 약간 밝게) + var titleColor = ThemeResourceHelper.Primary(this); + var dateColor = ThemeResourceHelper.HintFg(this); + + var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var title = new TextBlock + { + Text = item.Title, + FontSize = 12.5, + Foreground = titleColor, + TextTrimming = TextTrimming.CharacterEllipsis + }; + var date = new TextBlock + { + Text = item.UpdatedAtText, + FontSize = 10, + Foreground = dateColor, + Margin = new Thickness(0, 2, 0, 0) + }; + stack.Children.Add(title); + stack.Children.Add(date); + Grid.SetColumn(stack, 1); + grid.Children.Add(stack); + + // 카테고리 변경 버튼 (호버 시 표시) + var catBtn = new Button + { + Content = new TextBlock + { + Text = "\uE70F", // Edit + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = (ThemeResourceHelper.Secondary(this)) + }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Visibility = Visibility.Collapsed, + Padding = new Thickness(4), + ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경" + }; + var capturedId = item.Id; + catBtn.Click += (_, _) => ShowConversationMenu(capturedId); + Grid.SetColumn(catBtn, 2); + grid.Children.Add(catBtn); + + // 선택 시 좌측 액센트 바 + if (isSelected) + { + border.BorderBrush = ThemeResourceHelper.Accent(this); + border.BorderThickness = new Thickness(2, 0, 0, 0); + } + + border.Child = grid; + + // 호버 이벤트 — 배경 + 미세 확대 + border.RenderTransformOrigin = new Point(0.5, 0.5); + border.RenderTransform = new ScaleTransform(1, 1); + var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)); + border.MouseEnter += (_, _) => + { + if (!isSelected) + border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); + catBtn.Visibility = Visibility.Visible; + var st = border.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + }; + border.MouseLeave += (_, _) => + { + if (!isSelected) + border.Background = Brushes.Transparent; + catBtn.Visibility = Visibility.Collapsed; + var st = border.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + }; + + // 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환 + border.MouseLeftButtonDown += (_, _) => + { + try + { + if (isSelected) + { + // 이미 선택된 대화 → 제목 편집 모드 + EnterTitleEditMode(title, item.Id, titleColor); + return; + } + // 스트리밍 중이면 취소 + if (_isStreaming) + { + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _typingTimer.Stop(); + _elapsedTimer.Stop(); + _activeStreamText = null; + _elapsedLabel = null; + _isStreaming = false; + } + var conv = _storage.Load(item.Id); + if (conv != null) + { + // Tab 보정 + if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; + lock (_convLock) _currentConversation = conv; + _tabConversationId[_activeTab] = conv.Id; + UpdateChatTitle(); + RenderMessages(); + RefreshConversationList(); + } + } + catch (Exception ex) + { + LogService.Error($"대화 전환 오류: {ex.Message}"); + } + }; + + // 우클릭 → 대화 관리 메뉴 바로 표시 + border.MouseRightButtonUp += (_, me) => + { + me.Handled = true; + // 선택되지 않은 대화를 우클릭하면 먼저 선택 + if (!isSelected) + { + var conv = _storage.Load(item.Id); + if (conv != null) + { + if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; + lock (_convLock) _currentConversation = conv; + _tabConversationId[_activeTab] = conv.Id; + UpdateChatTitle(); + RenderMessages(); + } + } + // Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기 + Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), System.Windows.Threading.DispatcherPriority.Input); + }; + + 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) + { + RefreshConversationList(); + } + + private static string FormatDate(DateTime dt) + { + var diff = DateTime.Now - dt; + if (diff.TotalMinutes < 1) return "방금 전"; + if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전"; + if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전"; + return dt.ToString("MM/dd"); + } + + private void UpdateChatTitle() + { + lock (_convLock) + { + ChatTitle.Text = _currentConversation?.Title ?? ""; + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.HelpCommands.cs b/src/AxCopilot/Views/ChatWindow.HelpCommands.cs new file mode 100644 index 0000000..ee92a18 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.HelpCommands.cs @@ -0,0 +1,157 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── /help 도움말 창 ───────────────────────────────────────────────── + + private void ShowSlashHelpWindow() + { + var bg = ThemeResourceHelper.Background(this); + var fg = ThemeResourceHelper.Primary(this); + var fg2 = ThemeResourceHelper.Secondary(this); + var accent = ThemeResourceHelper.Accent(this); + var itemBg = ThemeResourceHelper.ItemBg(this); + var hoverBg = ThemeResourceHelper.HoverBg(this); + + var win = new Window + { + Title = "AX Agent — 슬래시 명령어 도움말", + Width = 560, Height = 640, MinWidth = 440, MinHeight = 500, + WindowStyle = WindowStyle.None, AllowsTransparency = true, + Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize, + WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this, + Icon = Icon, + }; + + var mainBorder = new Border + { + Background = bg, CornerRadius = new CornerRadius(16), + BorderBrush = ThemeResourceHelper.Border(this), + BorderThickness = new Thickness(1), Margin = new Thickness(10), + Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 }, + }; + + var rootGrid = new Grid(); + rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) }); + rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + // 헤더 + var headerBorder = new Border + { + CornerRadius = new CornerRadius(16, 16, 0, 0), + Background = new LinearGradientBrush( + Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204), + new Point(0, 0), new Point(1, 1)), + Padding = new Thickness(20, 0, 20, 0), + }; + var headerGrid = new Grid(); + var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }); + headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center }); + headerGrid.Children.Add(headerStack); + + var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center }; + closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255)); + closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent; + closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); }; + headerGrid.Children.Add(closeBtn); + headerBorder.Child = headerGrid; + Grid.SetRow(headerBorder, 0); + rootGrid.Children.Add(headerBorder); + + // 콘텐츠 + var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) }; + var contentPanel = new StackPanel(); + + // 설명 + contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 }); + + // 공통 명령어 섹션 + AddHelpSection(contentPanel, "📌 공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, + ("/summary", "텍스트/문서를 핵심 포인트 중심으로 요약합니다."), + ("/translate", "텍스트를 영어로 번역합니다. 원문의 톤을 유지합니다."), + ("/explain", "내용을 쉽고 자세하게 설명합니다. 예시를 포함합니다."), + ("/fix", "맞춤법, 문법, 자연스러운 표현을 교정합니다.")); + + // 개발 명령어 섹션 + AddHelpSection(contentPanel, "🛠️ 개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg, + ("/review", "Git diff를 분석하여 버그, 성능, 보안 이슈를 찾습니다."), + ("/pr", "변경사항을 PR 설명 형식(Summary, Changes, Test Plan)으로 요약합니다."), + ("/test", "코드에 대한 단위 테스트를 자동 생성합니다."), + ("/structure", "프로젝트의 폴더/파일 구조를 분석하고 설명합니다."), + ("/build", "프로젝트를 빌드합니다. 오류 발생 시 분석합니다."), + ("/search", "자연어로 코드베이스를 시맨틱 검색합니다.")); + + // 스킬 명령어 섹션 + var skills = SkillService.Skills; + if (skills.Count > 0) + { + var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray(); + AddHelpSection(contentPanel, "⚡ 스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems); + } + + // 사용 팁 + contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) }); + var tipPanel = new StackPanel(); + tipPanel.Children.Add(new TextBlock { Text = "💡 사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) }); + var tips = new[] + { + "/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.", + "파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.", + "스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.", + "Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.", + }; + foreach (var tip in tips) + { + tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 }); + } + contentPanel.Children.Add(tipPanel); + + scroll.Content = contentPanel; + Grid.SetRow(scroll, 1); + rootGrid.Children.Add(scroll); + + mainBorder.Child = rootGrid; + win.Content = mainBorder; + // 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지) + headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch (Exception) { /* 드래그 이동 실패 — 마우스 릴리즈 시 발생 가능 */ } }; + win.ShowDialog(); + } + + private static void AddHelpSection(StackPanel parent, string title, string subtitle, + Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg, + params (string Cmd, string Desc)[] items) + { + parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) }); + parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) }); + + foreach (var (cmd, desc) in items) + { + var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) }; + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = ThemeResourceHelper.Consolas }; + Grid.SetColumn(cmdText, 0); + grid.Children.Add(cmdText); + + var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + Grid.SetColumn(descText, 1); + grid.Children.Add(descText); + + row.Child = grid; + row.MouseEnter += (_, _) => row.Background = hoverBg; + row.MouseLeave += (_, _) => row.Background = itemBg; + parent.Children.Add(row); + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.PermissionMenu.cs b/src/AxCopilot/Views/ChatWindow.PermissionMenu.cs new file mode 100644 index 0000000..c487eee --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.PermissionMenu.cs @@ -0,0 +1,498 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 권한 메뉴 ───────────────────────────────────────────────────────── + + private void BtnPermission_Click(object sender, RoutedEventArgs e) + { + if (PermissionPopup == null) return; + PermissionItems.Children.Clear(); + + var levels = new (string Level, string Sym, string Desc, string Color)[] { + ("Ask", "\uE8D7", "매번 확인 — 파일 접근 시 사용자에게 묻습니다", "#4B5EFC"), + ("Auto", "\uE73E", "자동 허용 — 파일을 자동으로 읽고 씁니다", "#DD6B20"), + ("Deny", "\uE711", "접근 차단 — 파일 접근을 허용하지 않습니다", "#C50F1F"), + }; + var current = Llm.FilePermission; + foreach (var (level, sym, desc, color) in levels) + { + var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); + + // 라운드 코너 템플릿 (기본 Button 크롬 제거) + var template = new ControlTemplate(typeof(Button)); + var bdFactory = new FrameworkElementFactory(typeof(Border)); + bdFactory.SetValue(Border.BackgroundProperty, Brushes.Transparent); + bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 8, 12, 8)); + bdFactory.Name = "Bd"; + var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter)); + cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Left); + cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + bdFactory.AppendChild(cpFactory); + template.VisualTree = bdFactory; + // 호버 효과 + var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)), "Bd")); + template.Triggers.Add(hoverTrigger); + + var btn = new Button + { + Template = template, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + HorizontalContentAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 0, 1), + }; + ApplyHoverScaleAnimation(btn, 1.02); + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + // 커스텀 체크 아이콘 + sp.Children.Add(CreateCheckIcon(isActive)); + sp.Children.Add(new TextBlock + { + Text = sym, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 14, + Foreground = BrushFromHex(color), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + }); + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = level, FontSize = 13, FontWeight = FontWeights.Bold, + Foreground = BrushFromHex(color), + }); + textStack.Children.Add(new TextBlock + { + Text = desc, FontSize = 11, + Foreground = ThemeResourceHelper.Secondary(this), + TextWrapping = TextWrapping.Wrap, + MaxWidth = 220, + }); + sp.Children.Add(textStack); + btn.Content = sp; + + var capturedLevel = level; + btn.Click += (_, _) => + { + Llm.FilePermission = capturedLevel; + UpdatePermissionUI(); + SaveConversationSettings(); + PermissionPopup.IsOpen = false; + }; + PermissionItems.Children.Add(btn); + } + PermissionPopup.IsOpen = true; + } + + private bool _autoWarningDismissed; // Auto 경고 배너 사용자가 닫았는지 + + private void BtnAutoWarningClose_Click(object sender, RoutedEventArgs e) + { + _autoWarningDismissed = true; + if (AutoPermissionWarning != null) + AutoPermissionWarning.Visibility = Visibility.Collapsed; + } + + private void UpdatePermissionUI() + { + if (PermissionLabel == null || PermissionIcon == null) return; + var perm = Llm.FilePermission; + PermissionLabel.Text = perm; + PermissionIcon.Text = perm switch + { + "Auto" => "\uE73E", + "Deny" => "\uE711", + _ => "\uE8D7", + }; + + // Auto 모드일 때 경고 색상 + 배너 표시 + if (perm == "Auto") + { + var warnColor = new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20)); + PermissionLabel.Foreground = warnColor; + PermissionIcon.Foreground = warnColor; + // Auto 전환 시 새 대화에서만 1회 필수 표시 (기존 대화에서 이미 Auto였으면 숨김) + ChatConversation? convForWarn; + lock (_convLock) convForWarn = _currentConversation; + var isExisting = convForWarn != null && convForWarn.Messages.Count > 0 && convForWarn.Permission == "Auto"; + if (AutoPermissionWarning != null && !_autoWarningDismissed && !isExisting) + AutoPermissionWarning.Visibility = Visibility.Visible; + } + else + { + _autoWarningDismissed = false; // Auto가 아닌 모드로 전환하면 리셋 + var defaultFg = ThemeResourceHelper.Secondary(this); + var iconFg = perm == "Deny" ? new SolidColorBrush(Color.FromRgb(0xC5, 0x0F, 0x1F)) + : new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); // Ask = 파란색 + PermissionLabel.Foreground = defaultFg; + PermissionIcon.Foreground = iconFg; + if (AutoPermissionWarning != null) + AutoPermissionWarning.Visibility = Visibility.Collapsed; + } + } + + // ──── 데이터 활용 수준 메뉴 ──── + + private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (DataUsagePopup == null) return; + DataUsageItems.Children.Clear(); + + var options = new (string Key, string Sym, string Label, string Desc, string Color)[] + { + ("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10"), + ("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706"), + ("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽거나 참조하지 않습니다", "#9CA3AF"), + }; + + foreach (var (key, sym, label, desc, color) in options) + { + var isActive = key.Equals(_folderDataUsage, StringComparison.OrdinalIgnoreCase); + + var template = new ControlTemplate(typeof(Button)); + var bdFactory = new FrameworkElementFactory(typeof(Border)); + bdFactory.SetValue(Border.BackgroundProperty, Brushes.Transparent); + bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 8, 12, 8)); + bdFactory.Name = "Bd"; + var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter)); + cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Left); + cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + bdFactory.AppendChild(cpFactory); + template.VisualTree = bdFactory; + var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)), "Bd")); + template.Triggers.Add(hoverTrigger); + + var btn = new Button + { + Template = template, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + HorizontalContentAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 0, 1), + }; + ApplyHoverScaleAnimation(btn, 1.02); + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + // 커스텀 체크 아이콘 + sp.Children.Add(CreateCheckIcon(isActive)); + sp.Children.Add(new TextBlock + { + Text = sym, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 14, + Foreground = BrushFromHex(color), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + }); + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = label, FontSize = 13, FontWeight = FontWeights.Bold, + Foreground = BrushFromHex(color), + }); + textStack.Children.Add(new TextBlock + { + Text = desc, FontSize = 11, + Foreground = ThemeResourceHelper.Secondary(this), + TextWrapping = TextWrapping.Wrap, + MaxWidth = 240, + }); + sp.Children.Add(textStack); + btn.Content = sp; + + var capturedKey = key; + btn.Click += (_, _) => + { + _folderDataUsage = capturedKey; + UpdateDataUsageUI(); + SaveConversationSettings(); + DataUsagePopup.IsOpen = false; + }; + DataUsageItems.Children.Add(btn); + } + DataUsagePopup.IsOpen = true; + } + + private void UpdateDataUsageUI() + { + if (DataUsageLabel == null || DataUsageIcon == null) return; + var (label, icon, color) = _folderDataUsage switch + { + "passive" => ("소극", "\uE8FD", "#D97706"), + "none" => ("미사용", "\uE8D8", "#9CA3AF"), + _ => ("적극", "\uE9F5", "#107C10"), + }; + DataUsageLabel.Text = label; + DataUsageIcon.Text = icon; + DataUsageIcon.Foreground = BrushFromHex(color); + } + + /// Cowork/Code 탭 진입 시 설정의 기본 권한을 적용. + private void ApplyTabDefaultPermission() + { + if (_activeTab == "Chat") + { + // Chat 탭: 경고 배너 숨기고 기본 Ask 모드로 복원 + Llm.FilePermission = "Ask"; + UpdatePermissionUI(); + return; + } + var defaultPerm = Llm.DefaultAgentPermission; + if (!string.IsNullOrEmpty(defaultPerm)) + { + Llm.FilePermission = defaultPerm; + UpdatePermissionUI(); + } + } + + // ─── 파일 첨부 ───────────────────────────────────────────────────────── + + private void BtnAttach_Click(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Multiselect = true, + Title = "첨부할 파일을 선택하세요", + Filter = "모든 파일 (*.*)|*.*|텍스트 (*.txt;*.md;*.csv)|*.txt;*.md;*.csv|코드 (*.cs;*.py;*.js;*.ts)|*.cs;*.py;*.js;*.ts", + }; + + // 작업 폴더가 있으면 초기 경로 설정 + var workFolder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(workFolder) && System.IO.Directory.Exists(workFolder)) + dlg.InitialDirectory = workFolder; + + if (dlg.ShowDialog() != true) return; + + foreach (var file in dlg.FileNames) + AddAttachedFile(file); + } + + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" + }; + + private void AddAttachedFile(string filePath) + { + if (_attachedFiles.Contains(filePath)) return; + + // 파일 크기 제한 (10MB) + try + { + var fi = new System.IO.FileInfo(filePath); + if (fi.Length > 10 * 1024 * 1024) + { + CustomMessageBox.Show($"파일이 너무 큽니다 (10MB 초과):\n{fi.Name}", "첨부 제한", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // 이미지 파일 → Vision API용 base64 변환 + var ext = fi.Extension.ToLowerInvariant(); + if (ImageExtensions.Contains(ext) && Llm.EnableImageInput) + { + var maxKb = Llm.MaxImageSizeKb; + if (maxKb <= 0) maxKb = 5120; + if (fi.Length > maxKb * 1024) + { + CustomMessageBox.Show($"이미지가 너무 큽니다 ({fi.Length / 1024}KB, 최대 {maxKb}KB).", + "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var bytes = System.IO.File.ReadAllBytes(filePath); + var mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".webp" => "image/webp", + _ => "image/png", + }; + var attachment = new ImageAttachment + { + Base64 = Convert.ToBase64String(bytes), + MimeType = mimeType, + FileName = fi.Name, + }; + + // 중복 확인 + if (_pendingImages.Any(i => i.FileName == attachment.FileName)) return; + + _pendingImages.Add(attachment); + AddImagePreview(attachment); + return; + } + } + catch (Exception) { return; } + + _attachedFiles.Add(filePath); + RefreshAttachedFilesUI(); + } + + private void RemoveAttachedFile(string filePath) + { + _attachedFiles.Remove(filePath); + RefreshAttachedFilesUI(); + } + + private void RefreshAttachedFilesUI() + { + AttachedFilesPanel.Items.Clear(); + if (_attachedFiles.Count == 0) + { + AttachedFilesPanel.Visibility = Visibility.Collapsed; + return; + } + + AttachedFilesPanel.Visibility = Visibility.Visible; + var secondaryBrush = ThemeResourceHelper.Secondary(this); + var hintBg = ThemeResourceHelper.Hint(this); + + foreach (var file in _attachedFiles.ToList()) + { + var fileName = System.IO.Path.GetFileName(file); + var capturedFile = file; + + var chip = new Border + { + Background = hintBg, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 4, 4, 4), + Margin = new Thickness(0, 0, 4, 4), + Cursor = Cursors.Hand, + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = "\uE8A5", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 10, + Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), + }); + sp.Children.Add(new TextBlock + { + Text = fileName, FontSize = 11, Foreground = secondaryBrush, + VerticalAlignment = VerticalAlignment.Center, MaxWidth = 150, TextTrimming = TextTrimming.CharacterEllipsis, + ToolTip = file, + }); + var removeBtn = new Button + { + Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush }, + Background = Brushes.Transparent, BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), + }; + removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile); + sp.Children.Add(removeBtn); + chip.Child = sp; + AttachedFilesPanel.Items.Add(chip); + } + } + + /// 첨부 파일 내용을 시스템 메시지로 변환합니다. + private string BuildFileContextPrompt() + { + if (_attachedFiles.Count == 0) return ""; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n[첨부 파일 컨텍스트]"); + + foreach (var file in _attachedFiles) + { + try + { + var ext = System.IO.Path.GetExtension(file).ToLowerInvariant(); + var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz" + or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg" + or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt" + or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac" + or ".psd" or ".ai" or ".sketch" or ".fig" + or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite"; + if (isBinary) + { + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---"); + continue; + } + + var content = System.IO.File.ReadAllText(file); + // 최대 8000자로 제한 + if (content.Length > 8000) + content = content[..8000] + "\n... (이하 생략)"; + + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---"); + sb.AppendLine(content); + } + catch (Exception ex) + { + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---"); + } + } + + return sb.ToString(); + } + + private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e) + { + var newW = Width + e.HorizontalChange; + var newH = Height + e.VerticalChange; + if (newW >= MinWidth) Width = newW; + if (newH >= MinHeight) Height = newH; + } + + // ─── 사이드바 토글 ─────────────────────────────────────────────────── + + private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e) + { + _sidebarVisible = !_sidebarVisible; + if (_sidebarVisible) + { + // 사이드바 열기, 아이콘 바 숨기기 + IconBarColumn.Width = new GridLength(0); + IconBarPanel.Visibility = Visibility.Collapsed; + SidebarPanel.Visibility = Visibility.Visible; + ToggleSidebarIcon.Text = "\uE76B"; + AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200); + } + else + { + // 사이드바 닫기, 아이콘 바 표시 + SidebarColumn.MinWidth = 0; + ToggleSidebarIcon.Text = "\uE76C"; + AnimateSidebar(270, 0, () => + { + SidebarPanel.Visibility = Visibility.Collapsed; + IconBarColumn.Width = new GridLength(52); + IconBarPanel.Visibility = Visibility.Visible; + }); + } + } + + private void AnimateSidebar(double from, double to, Action? onComplete = null) + { + var duration = 200.0; + var start = DateTime.UtcNow; + var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; + EventHandler tickHandler = null!; + tickHandler = (_, _) => + { + var elapsed = (DateTime.UtcNow - start).TotalMilliseconds; + var t = Math.Min(elapsed / duration, 1.0); + t = 1 - (1 - t) * (1 - t); + SidebarColumn.Width = new GridLength(from + (to - from) * t); + if (elapsed >= duration) + { + timer.Stop(); + timer.Tick -= tickHandler; + SidebarColumn.Width = new GridLength(to); + onComplete?.Invoke(); + } + }; + timer.Tick += tickHandler; + timer.Start(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs new file mode 100644 index 0000000..02f5367 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs @@ -0,0 +1,1494 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 응답 재생성 ────────────────────────────────────────────────────── + + private async Task RegenerateLastAsync() + { + if (_isStreaming) return; + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // 마지막 assistant 메시지 제거 + lock (_convLock) + { + if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + + // UI에서 마지막 AI 응답 제거 + if (MessagePanel.Children.Count > 0) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 재전송 + await SendRegenerateAsync(conv); + } + + /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다. + private void ShowRetryWithFeedbackInput() + { + if (_isStreaming) return; + var accentBrush = ThemeResourceHelper.Accent(this); + var itemBg = ThemeResourceHelper.ItemBg(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var borderBrush = ThemeResourceHelper.Border(this); + + var container = new Border + { + Margin = new Thickness(40, 4, 40, 8), + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = itemBg, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = "어떻게 수정하면 좋을지 알려주세요:", + FontSize = 12, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + + var textBox = new TextBox + { + MinHeight = 38, + MaxHeight = 80, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + Background = ThemeResourceHelper.Background(this), + Foreground = primaryText, + CaretBrush = primaryText, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6, 10, 6), + }; + stack.Children.Add(textBox); + + var btnRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 8, 0, 0), + }; + + var sendBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(14, 6, 14, 6), + Cursor = Cursors.Hand, + Margin = new Thickness(6, 0, 0, 0), + }; + sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, 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; + MessagePanel.Children.Remove(container); + _ = RetryWithFeedbackAsync(feedback); + }; + + var cancelBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 6, 12, 6), + Cursor = Cursors.Hand, + }; + cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; + cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container); + + btnRow.Children.Add(cancelBtn); + btnRow.Children.Add(sendBtn); + stack.Children.Add(btnRow); + container.Child = stack; + + ApplyMessageEntryAnimation(container); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); + textBox.Focus(); + } + + /// 사용자 피드백과 함께 마지막 응답을 재생성합니다. + private async Task RetryWithFeedbackAsync(string feedback) + { + if (_isStreaming) return; + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // 마지막 assistant 메시지 제거 + lock (_convLock) + { + if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + + // UI에서 마지막 AI 응답 제거 + if (MessagePanel.Children.Count > 0) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 피드백을 사용자 메시지로 추가 + var feedbackMsg = new ChatMessage + { + Role = "user", + Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요." + }; + lock (_convLock) conv.Messages.Add(feedbackMsg); + + // 피드백 메시지 UI 표시 + AddMessageBubble("user", $"[수정 요청] {feedback}", true); + + // 재전송 + await SendRegenerateAsync(conv); + } + + private async Task SendRegenerateAsync(ChatConversation conv) + { + _isStreaming = true; + BtnSend.IsEnabled = false; + BtnSend.Visibility = Visibility.Collapsed; + BtnStop.Visibility = Visibility.Visible; + _streamCts = new CancellationTokenSource(); + + var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; + lock (_convLock) conv.Messages.Add(assistantMsg); + + var streamContainer = CreateStreamingContainer(out var streamText); + MessagePanel.Children.Add(streamContainer); + ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 + + var sb = new System.Text.StringBuilder(); + _activeStreamText = streamText; + _cachedStreamContent = ""; + _displayedLength = 0; + _cursorVisible = true; + _aiIconPulseStopped = false; + _cursorTimer.Start(); + _typingTimer.Start(); + _streamStartTime = DateTime.UtcNow; + _elapsedTimer.Start(); + SetStatus("에이전트 작업 중...", spinning: true); + + try + { + List sendMessages; + lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); + if (!string.IsNullOrEmpty(conv.SystemCommand)) + sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); + + await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) + { + sb.Append(chunk); + StopAiIconPulse(); + _cachedStreamContent = sb.ToString(); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + } + _cachedStreamContent = sb.ToString(); + assistantMsg.Content = _cachedStreamContent; + + // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) + var drainStart2 = DateTime.UtcNow; + while (_displayedLength < _cachedStreamContent.Length + && (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600) + { + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + } + } + catch (OperationCanceledException) + { + if (sb.Length == 0) sb.Append("(취소됨)"); + assistantMsg.Content = sb.ToString(); + } + catch (Exception ex) + { + var errMsg = $"⚠ 오류: {ex.Message}"; + sb.Clear(); sb.Append(errMsg); + assistantMsg.Content = errMsg; + AddRetryButton(); + } + finally + { + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 + StopRainbowGlow(); // 레인보우 글로우 종료 + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); + AutoScrollIfNeeded(); + + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + _tabConversationId[conv.Tab ?? _activeTab] = conv.Id; + RefreshConversationList(); + } + + /// 메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (최소 500, 최대 1200). + private double GetMessageMaxWidth() + { + var scrollWidth = MessageScroll.ActualWidth; + if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값 + // 좌우 마진(40+80=120)을 빼고 전체의 90% + var maxW = (scrollWidth - 120) * 0.90; + return Math.Clamp(maxW, 500, 1200); + } + + private StackPanel CreateStreamingContainer(out TextBlock streamText) + { + var msgMaxWidth = GetMessageMaxWidth(); + var container = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Left, + Width = msgMaxWidth, + MaxWidth = msgMaxWidth, + Margin = new Thickness(40, 8, 80, 8), + Opacity = 0, + RenderTransform = new TranslateTransform(0, 10) + }; + + // 컨테이너 페이드인 + 슬라이드 업 + container.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); + ((TranslateTransform)container.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) + { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); + + var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var aiIcon = new TextBlock + { + Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, + Foreground = ThemeResourceHelper.Accent(this), + VerticalAlignment = VerticalAlignment.Center + }; + // AI 아이콘 펄스 애니메이션 (응답 대기 중) + aiIcon.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) + { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, + EasingFunction = new SineEase() }); + _activeAiIcon = aiIcon; + Grid.SetColumn(aiIcon, 0); + headerGrid.Children.Add(aiIcon); + + var (streamAgentName, _, _) = GetAgentIdentity(); + var aiNameTb = new TextBlock + { + Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold, + Foreground = ThemeResourceHelper.Accent(this), + Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(aiNameTb, 1); + headerGrid.Children.Add(aiNameTb); + + // 실시간 경과 시간 (헤더 우측) + _elapsedLabel = new TextBlock + { + Text = "0s", + FontSize = 10.5, + Foreground = ThemeResourceHelper.Secondary(this), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.5, + }; + Grid.SetColumn(_elapsedLabel, 2); + headerGrid.Children.Add(_elapsedLabel); + + container.Children.Add(headerGrid); + + streamText = new TextBlock + { + Text = "\u258c", // 블록 커서만 표시 (첫 청크 전) + FontSize = 13.5, + Foreground = ThemeResourceHelper.Secondary(this), + TextWrapping = TextWrapping.Wrap, LineHeight = 22, + }; + container.Children.Add(streamText); + return container; + } + + // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── + + private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) + { + // 스트리밍 plaintext 블록 제거 + container.Children.Remove(streamText); + + // 마크다운 렌더링 + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var codeBgBrush = ThemeResourceHelper.Hint(this); + + var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); + mdPanel.Margin = new Thickness(0, 0, 0, 4); + mdPanel.Opacity = 0; + container.Children.Add(mdPanel); + mdPanel.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); + + // 액션 버튼 바 + 토큰 표시 + var btnColor = ThemeResourceHelper.Secondary(this); + var capturedContent = finalContent; + var actionBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 6, 0, 0) + }; + actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => + { + try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ } + })); + actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); + actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); + AddLinkedFeedbackButtons(actionBar, btnColor, message); + + container.Children.Add(actionBar); + + // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) + var elapsed = DateTime.UtcNow - _streamStartTime; + var elapsedText = elapsed.TotalSeconds < 60 + ? $"{elapsed.TotalSeconds:0.#}s" + : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + + var usage = _llm.LastTokenUsage; + // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 + var isAgentTab = _activeTab is "Cowork" or "Code"; + var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 + ? _agentCumulativeInputTokens + : usage?.PromptTokens ?? 0; + var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0 + ? _agentCumulativeOutputTokens + : usage?.CompletionTokens ?? 0; + + if (displayInput > 0 || displayOutput > 0) + { + UpdateStatusTokens(displayInput, displayOutput); + Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput); + } + string tokenText; + if (displayInput > 0 || displayOutput > 0) + tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; + else if (usage != null) + tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; + else + tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; + + var metaText = new TextBlock + { + Text = $"{elapsedText} · {tokenText}", + FontSize = 10.5, + Foreground = ThemeResourceHelper.Secondary(this), + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 6, 0, 0), + Opacity = 0.6, + }; + container.Children.Add(metaText); + + // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 + var chips = ParseSuggestionChips(finalContent); + if (chips.Count > 0) + { + var chipPanel = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 4), + HorizontalAlignment = HorizontalAlignment.Left, + }; + foreach (var (num, label) in chips) + { + var chipBorder = new Border + { + Background = ThemeResourceHelper.ItemBg(this), + BorderBrush = ThemeResourceHelper.Border(this), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 6), + Cursor = Cursors.Hand, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + chipBorder.Child = new TextBlock + { + Text = $"{num}. {label}", + FontSize = 12.5, + Foreground = ThemeResourceHelper.Primary(this), + }; + + var chipHover = ThemeResourceHelper.HoverBg(this); + var chipNormal = ThemeResourceHelper.ItemBg(this); + chipBorder.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } + }; + chipBorder.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } + }; + + var capturedLabel = $"{num}. {label}"; + var capturedPanel = chipPanel; + chipBorder.MouseLeftButtonDown += (_, _) => + { + // 칩 패널 제거 (1회용) + if (capturedPanel.Parent is Panel parent) + parent.Children.Remove(capturedPanel); + // 선택한 옵션을 사용자 메시지로 전송 + InputBox.Text = capturedLabel; + _ = SendMessageAsync(); + }; + chipPanel.Children.Add(chipBorder); + } + container.Children.Add(chipPanel); + } + } + + /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) + private static List<(string Num, string Label)> ParseSuggestionChips(string content) + { + var chips = new List<(string, string)>(); + if (string.IsNullOrEmpty(content)) return chips; + + var lines = content.Split('\n'); + // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) + var candidates = new List<(string, string)>(); + var lastBlockStart = -1; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 + var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); + if (m.Success) + { + if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) + { + if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + else + { + // 새로운 블록 시작 + lastBlockStart = i; + candidates.Clear(); + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + } + else if (!string.IsNullOrWhiteSpace(line)) + { + // 번호 목록이 아닌 줄이 나오면 블록 리셋 + lastBlockStart = -1; + candidates.Clear(); + } + // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) + } + + // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 + if (candidates.Count >= 2 && candidates.Count <= 10) + chips.AddRange(candidates); + + return chips; + } + + /// 토큰 수를 k/m 단위로 포맷 + private static string FormatTokenCount(int count) => count switch + { + >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", + >= 1_000 => $"{count / 1_000.0:0.#}k", + _ => count.ToString(), + }; + + /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) + private static int EstimateTokenCount(string text) + { + if (string.IsNullOrEmpty(text)) return 0; + // 한국어 문자 비율에 따라 가중 + int cjk = 0; + foreach (var c in text) + if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; + double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; + double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 + return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); + } + + // ─── 생성 중지 ────────────────────────────────────────────────────── + + private void StopGeneration() + { + _streamCts?.Cancel(); + } + + // ─── 대화 분기 (Fork) ────────────────────────────────────────────── + + private void ForkConversation(ChatConversation source, int atIndex) + { + var branchCount = _storage.LoadAllMeta() + .Count(m => m.ParentId == source.Id) + 1; + + var fork = new ChatConversation + { + Title = $"{source.Title} (분기 {branchCount})", + Tab = source.Tab, + Category = source.Category, + WorkFolder = source.WorkFolder, + SystemCommand = source.SystemCommand, + ParentId = source.Id, + BranchLabel = $"분기 {branchCount}", + BranchAtIndex = atIndex, + }; + + // 분기 시점까지의 메시지 복제 + for (int i = 0; i <= atIndex && i < source.Messages.Count; i++) + { + var m = source.Messages[i]; + fork.Messages.Add(new ChatMessage + { + Role = m.Role, + Content = m.Content, + Timestamp = m.Timestamp, + }); + } + + try + { + _storage.Save(fork); + ShowToast($"분기 생성: {fork.Title}"); + + // 분기 대화로 전환 + lock (_convLock) _currentConversation = fork; + ChatTitle.Text = fork.Title; + RenderMessages(); + RefreshConversationList(); + } + catch (Exception ex) + { + ShowToast($"분기 실패: {ex.Message}", "\uE783"); + } + } + + // ─── 커맨드 팔레트 ───────────────────────────────────────────────── + + private void OpenCommandPalette() + { + var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; + palette.ShowDialog(); + } + + private void ExecuteCommand(string commandId) + { + switch (commandId) + { + case "tab:chat": TabChat.IsChecked = true; break; + case "tab:cowork": TabCowork.IsChecked = true; break; + case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; + case "new_conversation": StartNewConversation(); break; + case "search_conversation": ToggleMessageSearch(); break; + case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; + case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; + case "open_statistics": new StatisticsWindow().Show(); break; + case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; + case "toggle_devmode": + var llm = Llm; + llm.DevMode = !llm.DevMode; + _settings.Save(); + UpdateAnalyzerButtonVisibility(); + ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); + break; + case "open_audit_log": + try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ } + break; + case "paste_clipboard": + try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ } + break; + case "export_conversation": ExportConversation(); break; + } + } + + private void ExportConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null || conv.Messages.Count == 0) return; + + var dlg = new Microsoft.Win32.SaveFileDialog + { + FileName = $"{conv.Title}", + DefaultExt = ".md", + Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" + }; + if (dlg.ShowDialog() != true) return; + + var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); + string content; + + if (ext == ".json") + { + content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + else if (dlg.FileName.EndsWith(".pdf.html")) + { + // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 + content = PdfExportService.BuildHtml(conv); + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + PdfExportService.OpenInBrowser(dlg.FileName); + ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); + return; + } + else if (ext == ".html") + { + content = ExportToHtml(conv); + } + else + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"# {conv.Title}"); + sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); + sb.AppendLine(); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var label = msg.Role == "user" ? "**사용자**" : "**AI**"; + sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); + sb.AppendLine(); + sb.AppendLine(msg.Content); + if (msg.AttachedFiles is { Count: > 0 }) + { + sb.AppendLine(); + sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); + } + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + } + content = sb.ToString(); + } + + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + } + + private static string ExportToHtml(ChatConversation conv) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(""); + sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); + sb.AppendLine(""); + sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); + sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var cls = msg.Role == "user" ? "user" : "ai"; + var label = msg.Role == "user" ? "사용자" : "AI"; + sb.AppendLine($"
"); + sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); + sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); + sb.AppendLine("
"); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + // ─── 버튼 이벤트 ────────────────────────────────────────────────────── + + private void ChatWindow_KeyDown(object sender, KeyEventArgs e) + { + var mod = Keyboard.Modifiers; + + // Ctrl 단축키 + if (mod == ModifierKeys.Control) + { + switch (e.Key) + { + case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.W: Close(); e.Handled = true; break; + case Key.E: ExportConversation(); e.Handled = true; break; + case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; + case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.F: ToggleMessageSearch(); e.Handled = true; break; + case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; + case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; + case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; + } + } + + // Ctrl+Shift 단축키 + if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) + { + switch (e.Key) + { + case Key.C: + // 마지막 AI 응답 복사 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv != null) + { + var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); + if (lastAi != null) + try { Clipboard.SetText(lastAi.Content); } catch (Exception) { /* 클립보드 접근 실패 */ } + } + e.Handled = true; + break; + case Key.R: + // 마지막 응답 재생성 + _ = RegenerateLastAsync(); + e.Handled = true; + break; + case Key.D: + // 모든 대화 삭제 + BtnDeleteAll_Click(this, new RoutedEventArgs()); + e.Handled = true; + break; + case Key.P: + // 커맨드 팔레트 + OpenCommandPalette(); + e.Handled = true; + break; + } + } + + // Escape: 검색 바 닫기 또는 스트리밍 중지 + if (e.Key == Key.Escape) + { + if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } + else if (_isStreaming) { StopGeneration(); e.Handled = true; } + } + + // 슬래시 명령 팝업 키 처리 + if (SlashPopup.IsOpen) + { + if (e.Key == Key.Escape) + { + SlashPopup.IsOpen = false; + _slashSelectedIndex = -1; + e.Handled = true; + } + else if (e.Key == Key.Up) + { + SlashPopup_ScrollByDelta(120); // 위로 1칸 + e.Handled = true; + } + else if (e.Key == Key.Down) + { + SlashPopup_ScrollByDelta(-120); // 아래로 1칸 + e.Handled = true; + } + else if (e.Key == Key.Enter && _slashSelectedIndex >= 0) + { + e.Handled = true; + ExecuteSlashSelectedItem(); + } + } + } + + private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); + + private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_agentLoop.IsPaused) + { + _agentLoop.Resume(); + PauseIcon.Text = "\uE769"; // 일시정지 아이콘 + BtnPause.ToolTip = "일시정지"; + } + else + { + _ = _agentLoop.PauseAsync(); + PauseIcon.Text = "\uE768"; // 재생 아이콘 + BtnPause.ToolTip = "재개"; + } + } + private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); + + // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── + + private List _searchMatchIndices = new(); + private int _searchCurrentIndex = -1; + + private void ToggleMessageSearch() + { + if (MessageSearchBar.Visibility == Visibility.Visible) + CloseMessageSearch(); + else + { + MessageSearchBar.Visibility = Visibility.Visible; + SearchTextBox.Focus(); + SearchTextBox.SelectAll(); + } + } + + private void CloseMessageSearch() + { + MessageSearchBar.Visibility = Visibility.Collapsed; + SearchTextBox.Text = ""; + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + // 하이라이트 제거 + ClearSearchHighlights(); + } + + private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + var query = SearchTextBox.Text.Trim(); + if (string.IsNullOrEmpty(query)) + { + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + ClearSearchHighlights(); + return; + } + + // 현재 대화의 메시지에서 검색 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + _searchMatchIndices.Clear(); + for (int i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) + _searchMatchIndices.Add(i); + } + + if (_searchMatchIndices.Count > 0) + { + _searchCurrentIndex = 0; + SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + else + { + _searchCurrentIndex = -1; + SearchResultCount.Text = "결과 없음"; + } + } + + private void SearchPrev_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchNext_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); + + private void HighlightSearchResult() + { + if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; + var msgIndex = _searchMatchIndices[_searchCurrentIndex]; + + if (msgIndex < MessagePanel.Children.Count) + { + var element = MessagePanel.Children[msgIndex] as FrameworkElement; + element?.BringIntoView(); + } + else if (MessagePanel.Children.Count > 0) + { + // 범위 밖이면 마지막 자식으로 이동 + (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); + } + } + + private void ClearSearchHighlights() + { + // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 + } + + // ─── 에러 복구 재시도 버튼 ────────────────────────────────────────────── + + private void AddRetryButton() + { + Dispatcher.Invoke(() => + { + var retryBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(40, 4, 80, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Cursor = System.Windows.Input.Cursors.Hand, + }; + var retrySp = new StackPanel { Orientation = Orientation.Horizontal }; + retrySp.Children.Add(new TextBlock + { + Text = "\uE72C", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + retrySp.Children.Add(new TextBlock + { + Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, + }); + retryBorder.Child = retrySp; + retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeftButtonUp += (_, _) => + { + lock (_convLock) + { + if (_currentConversation != null) + { + var lastIdx = _currentConversation.Messages.Count - 1; + if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant") + _currentConversation.Messages.RemoveAt(lastIdx); + } + } + _ = RegenerateLastAsync(); + }; + MessagePanel.Children.Add(retryBorder); + ForceScrollToEnd(); + }); + } + + // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── + + private void ShowMessageContextMenu(string content, string role) + { + var menu = CreateThemedContextMenu(); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + void AddItem(string icon, string label, Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 12, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; + mi.Click += (_, _) => action(); + menu.Items.Add(mi); + } + + // 복사 + AddItem("\uE8C8", "텍스트 복사", () => + { + try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + // 마크다운 복사 + AddItem("\uE943", "마크다운 복사", () => + { + try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + // 인용하여 답장 + AddItem("\uE97A", "인용하여 답장", () => + { + var quote = content.Length > 200 ? content[..200] + "..." : content; + var lines = quote.Split('\n'); + var quoted = string.Join("\n", lines.Select(l => $"> {l}")); + InputBox.Text = quoted + "\n\n"; + InputBox.Focus(); + InputBox.CaretIndex = InputBox.Text.Length; + }); + + menu.Items.Add(new Separator()); + + // 재생성 (AI 응답만) + if (role == "assistant") + { + AddItem("\uE72C", "응답 재생성", () => _ = RegenerateLastAsync()); + } + + // 대화 분기 (Fork) + AddItem("\uE8A5", "여기서 분기", () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); + if (idx < 0) return; + + ForkConversation(conv, idx); + }); + + menu.Items.Add(new Separator()); + + // 이후 메시지 모두 삭제 + var msgContent = content; + var msgRole = role; + AddItem("\uE74D", "이후 메시지 모두 삭제", () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); + if (idx < 0) return; + + var removeCount = conv.Messages.Count - idx; + if (MessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", + "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) + return; + + conv.Messages.RemoveRange(idx, removeCount); + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RenderMessages(); + ShowToast($"{removeCount}개 메시지 삭제됨"); + }); + + menu.IsOpen = true; + } + + // ─── 팁 알림 ────────────────────────────────────────────────────── + + private static readonly string[] Tips = + [ + "💡 작업 폴더에 AX.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", + "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", + "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", + "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", + "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", + "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", + "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", + "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", + "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", + "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", + "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", + "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", + "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", + "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", + "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", + "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", + "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", + "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", + "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", + "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", + "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", + "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", + "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", + "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", + ]; + private int _tipIndex; + private DispatcherTimer? _tipDismissTimer; + + private void ShowRandomTip() + { + if (!Llm.ShowTips) return; + if (_activeTab != "Cowork" && _activeTab != "Code") return; + + var tip = Tips[_tipIndex % Tips.Length]; + _tipIndex++; + + // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) + ShowTip(tip); + } + + private void ShowTip(string message) + { + _tipDismissTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = "\uE82F"; // 전구 아이콘 + ToastBorder.Visibility = Visibility.Visible; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + var duration = Llm.TipDurationSeconds; + if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음) + + _tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; + _tipDismissTimer.Tick += (_, _) => + { + _tipDismissTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _tipDismissTimer.Start(); + } + + // ─── 프로젝트 문맥 파일 (AX.md) ────────────────────────────────── + + /// + /// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. + /// Claude Code와 동일한 파일명/형식을 사용합니다. + /// + private static string LoadProjectContext(string workFolder) + { + if (string.IsNullOrEmpty(workFolder)) return ""; + + // Phase 30-C: HierarchicalMemoryService — 4-layer 계층 메모리 통합 조회 + try + { + var hierMemory = new AxCopilot.Services.Agent.HierarchicalMemoryService(); + var merged = hierMemory.BuildMergedContext(workFolder, 8000); + if (!string.IsNullOrWhiteSpace(merged)) + { + // @include 지시어 해석 + if (merged.Contains("@")) + { + try + { + var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); + merged = resolver.ResolveAsync(merged, workFolder).GetAwaiter().GetResult(); + } + catch (Exception) { /* @include 실패 시 원본 유지 */ } + } + return $"\n## Project Context (Hierarchical Memory)\n{merged}\n"; + } + } + catch (Exception) { /* 계층 메모리 실패 시 레거시 폴백 */ } + + // 레거시 폴백: 단일 AX.md 탐색 (작업 폴더 → 상위 폴더 순) + var searchDir = workFolder; + for (int i = 0; i < 3; i++) + { + if (string.IsNullOrEmpty(searchDir)) break; + var filePath = System.IO.Path.Combine(searchDir, "AX.md"); + if (System.IO.File.Exists(filePath)) + { + try + { + var content = System.IO.File.ReadAllText(filePath); + if (content.Contains("@")) + { + try + { + var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); + var baseDir = System.IO.Path.GetDirectoryName(filePath) ?? workFolder; + content = resolver.ResolveAsync(content, baseDir).GetAwaiter().GetResult(); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + } + if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; + return $"\n## Project Context (from AX.md)\n{content}\n"; + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + } + searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; + } + return ""; + } + + /// Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입. + private string BuildPathBasedSkillSection() + { + try + { + // 현재 대화에서 언급된 파일 경로 추출 (최근 메시지에서) + var recentFiles = GetRecentlyMentionedFiles(); + if (recentFiles.Count == 0) return ""; + + var allSkills = AxCopilot.Services.Agent.SkillService.Skills; + var activator = new AxCopilot.Services.Agent.PathBasedSkillActivator(); + + var matchedSkills = new List(); + foreach (var file in recentFiles) + { + var matches = activator.GetActiveSkillsForFile(allSkills, file); + foreach (var m in matches) + { + if (!matchedSkills.Any(s => s.Name == m.Name)) + matchedSkills.Add(m); + } + } + + return activator.BuildSkillContextInjection(matchedSkills); + } + catch (Exception) { return ""; } + } + + /// 최근 대화에서 파일 경로를 추출합니다. + private List GetRecentlyMentionedFiles() + { + var files = new List(); + if (_currentConversation?.Messages == null) return files; + + // 최근 5개 메시지에서 파일 경로 패턴 탐색 + var recent = _currentConversation.Messages.TakeLast(5); + foreach (var msg in recent) + { + if (string.IsNullOrEmpty(msg.Content)) continue; + // 간단한 파일 경로 패턴: 확장자가 있는 경로 + var pathMatches = System.Text.RegularExpressions.Regex.Matches( + msg.Content, @"[\w./\\-]+\.\w{1,10}"); + foreach (System.Text.RegularExpressions.Match m in pathMatches) + { + var path = m.Value; + if (path.Contains('.') && !path.StartsWith("http")) + files.Add(path); + } + } + return files.Distinct().Take(10).ToList(); + } + + // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── + + private DispatcherTimer? _rainbowTimer; + private DateTime _rainbowStartTime; + + /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). + private void PlayRainbowGlow() + { + if (!Llm.EnableChatRainbowGlow) return; + + _rainbowTimer?.Stop(); + _rainbowStartTime = DateTime.UtcNow; + + // 페이드인 (빠르게) + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))); + + // 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속 + _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _rainbowTimer.Tick += (_, _) => + { + var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; + + // 그라데이션 오프셋 회전 + var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게) + var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; + if (brush == null) return; + + // 시작/끝점 회전 (원형 이동) + var angle = shift * Math.PI * 2; + brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); + brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); + }; + _rainbowTimer.Start(); + } + + /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. + private void StopRainbowGlow() + { + _rainbowTimer?.Stop(); + _rainbowTimer = null; + if (InputGlowBorder.Opacity > 0) + { + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( + InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); + fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0; + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + } + } + + // ─── 토스트 알림 ────────────────────────────────────────────────────── + + private DispatcherTimer? _toastHideTimer; + + private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) + { + _toastHideTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = icon; + ToastBorder.Visibility = Visibility.Visible; + + // 페이드인 + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); + + // 자동 숨기기 + _toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; + _toastHideTimer.Tick += (_, _) => + { + _toastHideTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _toastHideTimer.Start(); + } + + // ─── 하단 상태바 ────────────────────────────────────────────────────── + + private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard; + + private void UpdateStatusBar(AgentEvent evt) + { + var toolLabel = evt.ToolName switch + { + "file_read" or "document_read" => "파일 읽기", + "file_write" => "파일 쓰기", + "file_edit" => "파일 수정", + "html_create" => "HTML 생성", + "xlsx_create" => "Excel 생성", + "docx_create" => "Word 생성", + "csv_create" => "CSV 생성", + "md_create" => "Markdown 생성", + "folder_map" => "폴더 탐색", + "glob" => "파일 검색", + "grep" => "내용 검색", + "process" => "명령 실행", + _ => evt.ToolName, + }; + + switch (evt.Type) + { + case AgentEventType.Thinking: + SetStatus("생각 중...", spinning: true); + break; + case AgentEventType.Planning: + SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true); + break; + case AgentEventType.ToolCall: + SetStatus($"{toolLabel} 실행 중...", spinning: true); + break; + case AgentEventType.ToolResult: + SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false); + break; + case AgentEventType.StepStart: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.StepDone: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true); + break; + case AgentEventType.SkillCall: + SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.Complete: + SetStatus("작업 완료", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Error: + SetStatus("오류 발생", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Paused: + SetStatus("⏸ 일시정지", spinning: false); + break; + case AgentEventType.Resumed: + SetStatus("▶ 재개됨", spinning: true); + break; + } + } + + private void SetStatus(string text, bool spinning) + { + if (StatusLabel != null) StatusLabel.Text = text; + if (spinning) StartStatusAnimation(); + } + + private void StartStatusAnimation() + { + if (_statusSpinStoryboard != null) return; + + var anim = new System.Windows.Media.Animation.DoubleAnimation + { + From = 0, To = 360, + Duration = TimeSpan.FromSeconds(2), + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + }; + + _statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard(); + System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim, + new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); + _statusSpinStoryboard.Children.Add(anim); + _statusSpinStoryboard.Begin(); + } + + private void StopStatusAnimation() + { + _statusSpinStoryboard?.Stop(); + _statusSpinStoryboard = null; + } + + private void SetStatusIdle() + { + StopStatusAnimation(); + if (StatusLabel != null) StatusLabel.Text = "대기 중"; + if (StatusElapsed != null) StatusElapsed.Text = ""; + if (StatusTokens != null) StatusTokens.Text = ""; + } + + private void UpdateStatusTokens(int inputTokens, int outputTokens) + { + if (StatusTokens == null) return; + var llm = Llm; + var (inCost, outCost) = Services.TokenEstimator.EstimateCost( + inputTokens, outputTokens, llm.Service, llm.Model); + var totalCost = inCost + outCost; + var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : ""; + StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}"; + } + + private static string TruncateForStatus(string? text, int max = 40) + { + if (string.IsNullOrEmpty(text)) return ""; + return text.Length <= max ? text : text[..max] + "…"; + } + + // ─── 헬퍼 ───────────────────────────────────────────────────────────── + private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) + { + var c = ThemeResourceHelper.HexColor(hex); + return new System.Windows.Media.SolidColorBrush(c); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.Sending.cs b/src/AxCopilot/Views/ChatWindow.Sending.cs new file mode 100644 index 0000000..16f5450 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.Sending.cs @@ -0,0 +1,720 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 메시지 등장 애니메이션 ────────────────────────────────────────── + + private static void ApplyMessageEntryAnimation(FrameworkElement element) + { + element.Opacity = 0; + element.RenderTransform = new TranslateTransform(0, 16); + element.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); + ((TranslateTransform)element.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); + } + + // ─── 메시지 편집 ────────────────────────────────────────────────────── + + private bool _isEditing; // 편집 모드 중복 방지 + + private void EnterEditMode(StackPanel wrapper, string originalText) + { + if (_isStreaming || _isEditing) return; + _isEditing = true; + + // wrapper 위치(인덱스) 기억 + var idx = MessagePanel.Children.IndexOf(wrapper); + if (idx < 0) { _isEditing = false; return; } + + // 편집 UI 생성 + var editPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Right, + MaxWidth = 540, + Margin = wrapper.Margin, + }; + + var editBox = new TextBox + { + Text = originalText, + FontSize = 13.5, + Foreground = ThemeResourceHelper.Primary(this), + Background = ThemeResourceHelper.ItemBg(this), + CaretBrush = ThemeResourceHelper.Accent(this), + BorderBrush = ThemeResourceHelper.Accent(this), + BorderThickness = new Thickness(1.5), + Padding = new Thickness(14, 10, 14, 10), + TextWrapping = TextWrapping.Wrap, + AcceptsReturn = false, + MaxHeight = 200, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + // 둥근 모서리 + var editBorder = new Border + { + CornerRadius = new CornerRadius(14), + Child = editBox, + ClipToBounds = true, + }; + editPanel.Children.Add(editBorder); + + // 버튼 바 + var btnBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 6, 0, 0), + }; + + var accentBrush = ThemeResourceHelper.Accent(this); + var secondaryBrush = ThemeResourceHelper.Secondary(this); + + // 취소 버튼 + var cancelBtn = new Button + { + Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 0, 6, 0), + }; + cancelBtn.Click += (_, _) => + { + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + MessagePanel.Children[idx] = wrapper; // 원래 버블 복원 + }; + btnBar.Children.Add(cancelBtn); + + // 전송 버튼 + var sendBtn = new Button + { + Cursor = Cursors.Hand, + Padding = new Thickness(0), + }; + sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( + "" + + "" + + "" + + ""); + sendBtn.Click += (_, _) => + { + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + _ = SubmitEditAsync(idx, newText); + }; + btnBar.Children.Add(sendBtn); + + editPanel.Children.Add(btnBar); + + // 기존 wrapper → editPanel 교체 + MessagePanel.Children[idx] = editPanel; + + // Enter 키로도 전송 + editBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) + { + ke.Handled = true; + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + _ = SubmitEditAsync(idx, newText); + } + if (ke.Key == Key.Escape) + { + ke.Handled = true; + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + MessagePanel.Children[idx] = wrapper; + } + }; + + editBox.Focus(); + editBox.SelectAll(); + } + + private async Task SubmitEditAsync(int bubbleIndex, string newText) + { + _isEditing = false; + if (_isStreaming) return; + + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // bubbleIndex에 해당하는 user 메시지 찾기 + int userMsgIdx = -1; + int uiIdx = 0; + lock (_convLock) + { + for (int i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Role == "system") continue; + if (uiIdx == bubbleIndex) + { + userMsgIdx = i; + break; + } + uiIdx++; + } + } + + if (userMsgIdx < 0) return; + + // 데이터 모델에서 편집된 메시지 이후 모두 제거 + lock (_convLock) + { + conv.Messages[userMsgIdx].Content = newText; + while (conv.Messages.Count > userMsgIdx + 1) + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + + // UI에서 편집된 버블 이후 모두 제거 + while (MessagePanel.Children.Count > bubbleIndex + 1) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 편집된 메시지를 새 버블로 교체 + MessagePanel.Children.RemoveAt(bubbleIndex); + AddMessageBubble("user", newText, animate: false); + + // AI 재응답 + await SendRegenerateAsync(conv); + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RefreshConversationList(); + } + + // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── + + private void StopAiIconPulse() + { + if (_aiIconPulseStopped || _activeAiIcon == null) return; + _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null); + _activeAiIcon.Opacity = 1.0; + _activeAiIcon = null; + _aiIconPulseStopped = true; + } + + private void CursorTimer_Tick(object? sender, EventArgs e) + { + _cursorVisible = !_cursorVisible; + // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당 + if (_activeStreamText != null && _displayedLength > 0) + { + var displayed = _cachedStreamContent.Length > 0 + ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)] + : ""; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + } + } + + private void ElapsedTimer_Tick(object? sender, EventArgs e) + { + var elapsed = DateTime.UtcNow - _streamStartTime; + var sec = (int)elapsed.TotalSeconds; + if (_elapsedLabel != null) + _elapsedLabel.Text = $"{sec}s"; + + // 하단 상태바 시간 갱신 + if (StatusElapsed != null) + StatusElapsed.Text = $"{sec}초"; + } + + private void TypingTimer_Tick(object? sender, EventArgs e) + { + if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return; + + var targetLen = _cachedStreamContent.Length; + if (_displayedLength >= targetLen) return; + + // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응 + var pending = targetLen - _displayedLength; + int step; + if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기 + else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속 + else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자 + + _displayedLength += step; + + var displayed = _cachedStreamContent[.._displayedLength]; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + + // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발) + if (!_userScrolled) + MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); + } + + // ─── 전송 ────────────────────────────────────────────────────────────── + + public void SendInitialMessage(string message) + { + StartNewConversation(); + InputBox.Text = message; + _ = SendMessageAsync(); + } + + private void StartNewConversation() + { + // 현재 대화가 있으면 저장 후 새 대화 시작 + lock (_convLock) + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + _currentConversation = new ChatConversation { Tab = _activeTab }; + // 작업 폴더가 설정에 있으면 새 대화에 자동 연결 (Cowork/Code 탭) + var workFolder = Llm.WorkFolder; + if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") + _currentConversation.WorkFolder = workFolder; + } + // 탭 기억 초기화 (새 대화이므로) + _tabConversationId[_activeTab] = null; + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + if (_activeTab == "Cowork") BuildBottomBar(); + } + + /// 설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다. + private void RestoreLastConversations() + { + var saved = Llm.LastConversationIds; + if (saved == null || saved.Count == 0) return; + + foreach (var kv in saved) + { + if (_tabConversationId.ContainsKey(kv.Key) && !string.IsNullOrEmpty(kv.Value)) + _tabConversationId[kv.Key] = kv.Value; + } + + // 현재 활성 탭의 대화를 즉시 로드 + var currentTabId = _tabConversationId.GetValueOrDefault(_activeTab); + if (!string.IsNullOrEmpty(currentTabId)) + { + var conv = _storage.Load(currentTabId); + if (conv != null) + { + if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; + lock (_convLock) _currentConversation = conv; + MessagePanel.Children.Clear(); + foreach (var msg in conv.Messages) + AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); + EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + UpdateChatTitle(); + UpdateFolderBar(); + LoadConversationSettings(); + } + } + } + + /// 현재 _tabConversationId를 설정에 저장합니다. + private void SaveLastConversations() + { + var dict = new Dictionary(); + foreach (var kv in _tabConversationId) + { + if (!string.IsNullOrEmpty(kv.Value)) + dict[kv.Key] = kv.Value; + } + Llm.LastConversationIds = dict; + try { _settings.Save(); } catch (Exception) { /* 설정 저장 실패 */ } + } + + private async void BtnSend_Click(object sender, RoutedEventArgs e) => await SendMessageAsync(); + + private async void InputBox_PreviewKeyDown(object sender, KeyEventArgs e) + { + // Ctrl+V: 클립보드 이미지 붙여넣기 + if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + if (TryPasteClipboardImage()) + { + e.Handled = true; + return; + } + // 이미지가 아니면 기본 텍스트 붙여넣기로 위임 + } + + if (e.Key == Key.Enter) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + // Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임) + return; + } + + // 슬래시 팝업이 열려 있으면 선택된 항목 실행 + if (SlashPopup.IsOpen && _slashSelectedIndex >= 0) + { + e.Handled = true; + ExecuteSlashSelectedItem(); + return; + } + + // /help 직접 입력 시 도움말 창 표시 + if (InputBox.Text.Trim().Equals("/help", StringComparison.OrdinalIgnoreCase)) + { + e.Handled = true; + InputBox.Text = ""; + SlashPopup.IsOpen = false; + ShowSlashHelpWindow(); + return; + } + + // Enter만 → 메시지 전송 + e.Handled = true; + await SendMessageAsync(); + } + } + + /// 클립보드에 이미지가 있으면 붙여넣기. 성공 시 true. + private bool TryPasteClipboardImage() + { + if (!Llm.EnableImageInput) return false; + if (!Clipboard.ContainsImage()) return false; + + try + { + var img = Clipboard.GetImage(); + if (img == null) return false; + + // base64 인코딩 + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(img)); + using var ms = new System.IO.MemoryStream(); + encoder.Save(ms); + var bytes = ms.ToArray(); + + // 크기 제한 확인 + var maxKb = Llm.MaxImageSizeKb; + if (maxKb <= 0) maxKb = 5120; + if (bytes.Length > maxKb * 1024) + { + CustomMessageBox.Show($"이미지가 너무 큽니다 ({bytes.Length / 1024}KB, 최대 {maxKb}KB).", + "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); + return true; // 처리됨 (에러이지만 이미지였음) + } + + var base64 = Convert.ToBase64String(bytes); + var attachment = new ImageAttachment + { + Base64 = base64, + MimeType = "image/png", + FileName = $"clipboard_{DateTime.Now:HHmmss}.png", + }; + + _pendingImages.Add(attachment); + AddImagePreview(attachment, img); + return true; + } + catch (Exception ex) + { + Services.LogService.Debug($"클립보드 이미지 붙여넣기 실패: {ex.Message}"); + return false; + } + } + + /// 이미지 미리보기 UI 추가. + private void AddImagePreview(ImageAttachment attachment, System.Windows.Media.Imaging.BitmapSource? thumbnail = null) + { + var secondaryBrush = ThemeResourceHelper.Secondary(this); + var hintBg = ThemeResourceHelper.Hint(this); + + var chip = new Border + { + Background = hintBg, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(4), + Margin = new Thickness(0, 0, 4, 4), + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + // 썸네일 이미지 + if (thumbnail != null) + { + sp.Children.Add(new System.Windows.Controls.Image + { + Source = thumbnail, + MaxHeight = 48, MaxWidth = 64, + Stretch = Stretch.Uniform, + Margin = new Thickness(0, 0, 4, 0), + }); + } + else + { + // base64에서 썸네일 생성 + sp.Children.Add(new TextBlock + { + Text = "\uE8B9", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 16, + Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(2, 0, 4, 0), + }); + } + + sp.Children.Add(new TextBlock + { + Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush, + VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + var capturedAttachment = attachment; + var capturedChip = chip; + var removeBtn = new Button + { + Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush }, + Background = Brushes.Transparent, BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), + }; + removeBtn.Click += (_, _) => + { + _pendingImages.Remove(capturedAttachment); + AttachedFilesPanel.Items.Remove(capturedChip); + if (_pendingImages.Count == 0 && _attachedFiles.Count == 0) + AttachedFilesPanel.Visibility = Visibility.Collapsed; + }; + sp.Children.Add(removeBtn); + chip.Child = sp; + + AttachedFilesPanel.Items.Add(chip); + AttachedFilesPanel.Visibility = Visibility.Visible; + } + + private async Task SendMessageAsync() + { + var rawText = InputBox.Text.Trim(); + + // 슬래시 칩이 활성화된 경우 명령어 앞에 붙임 + var text = _activeSlashCmd != null + ? (_activeSlashCmd + " " + rawText).Trim() + : rawText; + HideSlashChip(restoreText: false); + + if (string.IsNullOrEmpty(text) || _isStreaming) return; + + // placeholder 정리 + ClearPromptCardPlaceholder(); + + // 슬래시 명령어 처리 + var (slashSystem, displayText) = ParseSlashCommand(text); + + // 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처 + var originTab = _activeTab; + + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) _currentConversation = new ChatConversation { Tab = _activeTab }; + conv = _currentConversation; + } + + var userMsg = new ChatMessage { Role = "user", Content = text }; + lock (_convLock) conv.Messages.Add(userMsg); + + if (conv.Messages.Count(m => m.Role == "user") == 1) + conv.Title = text.Length > 30 ? text[..30] + "…" : text; + + UpdateChatTitle(); + AddMessageBubble("user", text); + InputBox.Text = ""; + EmptyState.Visibility = Visibility.Collapsed; + + // 대화 통계 기록 + Services.UsageStatisticsService.RecordChat(_activeTab); + + ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동 + PlayRainbowGlow(); // 무지개 글로우 애니메이션 + + _isStreaming = true; + BtnSend.IsEnabled = false; + BtnSend.Visibility = Visibility.Collapsed; + BtnStop.Visibility = Visibility.Visible; + if (_activeTab == "Cowork" || _activeTab == "Code") + BtnPause.Visibility = Visibility.Visible; + _streamCts = new CancellationTokenSource(); + + var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; + lock (_convLock) conv.Messages.Add(assistantMsg); + + // 어시스턴트 스트리밍 컨테이너 + var streamContainer = CreateStreamingContainer(out var streamText); + MessagePanel.Children.Add(streamContainer); + ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 + + var sb = new System.Text.StringBuilder(); + _activeStreamText = streamText; + _cachedStreamContent = ""; + _displayedLength = 0; + _cursorVisible = true; + _aiIconPulseStopped = false; + _cursorTimer.Start(); + _typingTimer.Start(); + _streamStartTime = DateTime.UtcNow; + _elapsedTimer.Start(); + SetStatus("응답 생성 중...", spinning: true); + + // ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ── + ModelRouteResult? routeResult = null; + + try + { + List sendMessages; + lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); + + // 시스템 명령어가 있으면 삽입 + if (!string.IsNullOrEmpty(conv.SystemCommand)) + sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); + + // 슬래시 명령어 시스템 프롬프트 삽입 + if (!string.IsNullOrEmpty(slashSystem)) + sendMessages.Insert(0, new ChatMessage { Role = "system", Content = slashSystem }); + + // 첨부 파일 컨텍스트 삽입 + if (_attachedFiles.Count > 0) + { + var fileContext = BuildFileContextPrompt(); + if (!string.IsNullOrEmpty(fileContext)) + { + var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); + if (lastUserIdx >= 0) + sendMessages[lastUserIdx] = new ChatMessage { Role = "user", Content = sendMessages[lastUserIdx].Content + fileContext }; + } + // 첨부 파일 목록 기록 후 항상 정리 (파일 읽기 실패해도) + userMsg.AttachedFiles = _attachedFiles.ToList(); + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + } + + // ── 이미지 첨부 ── + if (_pendingImages.Count > 0) + { + userMsg.Images = _pendingImages.ToList(); + // 마지막 사용자 메시지에 이미지 데이터 연결 + var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); + if (lastUserIdx >= 0) + sendMessages[lastUserIdx] = new ChatMessage + { + Role = "user", + Content = sendMessages[lastUserIdx].Content, + Images = _pendingImages.ToList(), + }; + _pendingImages.Clear(); + AttachedFilesPanel.Items.Clear(); + if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; + } + + // ── 자동 모델 라우팅 ── + if (Llm.EnableAutoRouter) + { + routeResult = _router.Route(text); + if (routeResult != null) + { + _llm.PushRouteOverride(routeResult.Service, routeResult.Model); + SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true); + } + } + + if (_activeTab is "Cowork" or "Code") + { + // Phase 34: Cowork/Code 공통 에이전트 루프 실행 + var agentResponse = await RunAgentLoopAsync(_activeTab, sendMessages, _streamCts!.Token); + sb.Append(agentResponse); + assistantMsg.Content = agentResponse; + StopAiIconPulse(); + _cachedStreamContent = agentResponse; + } + else if (Llm.Streaming) + { + await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) + { + sb.Append(chunk); + StopAiIconPulse(); + _cachedStreamContent = sb.ToString(); + // UI 스레드에 제어 양보 — DispatcherTimer가 화면 갱신할 수 있도록 + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + } + _cachedStreamContent = sb.ToString(); + assistantMsg.Content = _cachedStreamContent; + + // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) + var drainStart = DateTime.UtcNow; + while (_displayedLength < _cachedStreamContent.Length + && (DateTime.UtcNow - drainStart).TotalMilliseconds < 600) + { + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + } + } + else + { + var response = await _llm.SendAsync(sendMessages, _streamCts.Token); + sb.Append(response); + assistantMsg.Content = response; + } + } + catch (OperationCanceledException) + { + if (sb.Length == 0) sb.Append("(취소됨)"); + assistantMsg.Content = sb.ToString(); + } + catch (Exception ex) + { + var errMsg = $"⚠ 오류: {ex.Message}"; + sb.Clear(); sb.Append(errMsg); + assistantMsg.Content = errMsg; + AddRetryButton(); + } + finally + { + // 자동 라우팅 오버라이드 해제 + if (routeResult != null) + { + _llm.ClearRouteOverride(); + UpdateModelLabel(); + } + + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 + StopRainbowGlow(); // 레인보우 글로우 종료 + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + // 스트리밍 plaintext → 마크다운 렌더링으로 교체 + FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); + AutoScrollIfNeeded(); + + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + _tabConversationId[originTab] = conv.Id; + RefreshConversationList(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.WorkFolder.cs b/src/AxCopilot/Views/ChatWindow.WorkFolder.cs new file mode 100644 index 0000000..68fd189 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.WorkFolder.cs @@ -0,0 +1,359 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 작업 폴더 ───────────────────────────────────────────────────────── + + private readonly List _attachedFiles = new(); + private readonly List _pendingImages = new(); + + private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu(); + + private void ShowFolderMenu() + { + FolderMenuItems.Children.Clear(); + + var accentBrush = ThemeResourceHelper.Accent(this); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + // 최근 폴더 목록 + var maxDisplay = Math.Clamp(Llm.MaxRecentFolders, 3, 30); + var recentFolders = Llm.RecentWorkFolders + .Where(p => IsPathAllowed(p) && System.IO.Directory.Exists(p)) + .Take(maxDisplay) + .ToList(); + + if (recentFolders.Count > 0) + { + FolderMenuItems.Children.Add(new TextBlock + { + Text = "최근 폴더", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(10, 6, 10, 4), + }); + + var currentFolder = GetCurrentWorkFolder(); + foreach (var folder in recentFolders) + { + var isActive = folder.Equals(currentFolder, StringComparison.OrdinalIgnoreCase); + var displayName = System.IO.Path.GetFileName(folder); + if (string.IsNullOrEmpty(displayName)) displayName = folder; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + if (isActive) + { + var checkEl = CreateSimpleCheck(accentBrush, 14); + checkEl.Margin = new Thickness(0, 0, 8, 0); + sp.Children.Add(checkEl); + } + var nameBlock = new TextBlock + { + Text = displayName, + FontSize = 14, + FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + MaxWidth = 340, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + sp.Children.Add(nameBlock); + + var itemBorder = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(10, 7, 10, 7), + ToolTip = folder, + }; + itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; + itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + var capturedPath = folder; + itemBorder.MouseLeftButtonUp += (_, _) => + { + FolderMenuPopup.IsOpen = false; + SetWorkFolder(capturedPath); + }; + // 우클릭 → 컨텍스트 메뉴 (삭제, 폴더 열기) + itemBorder.MouseRightButtonUp += (_, re) => + { + re.Handled = true; + ShowRecentFolderContextMenu(capturedPath); + }; + FolderMenuItems.Children.Add(itemBorder); + } + + // 구분선 + FolderMenuItems.Children.Add(new Border + { + Height = 1, + Background = ThemeResourceHelper.Border(this), + Margin = new Thickness(8, 4, 8, 4), + Opacity = 0.5, + }); + } + + // 폴더 찾아보기 버튼 + var browseSp = new StackPanel { Orientation = Orientation.Horizontal }; + browseSp.Children.Add(new TextBlock + { + Text = "\uED25", + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 14, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + browseSp.Children.Add(new TextBlock + { + Text = "폴더 찾아보기...", + FontSize = 14, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var browseBorder = new Border + { + Child = browseSp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(10, 7, 10, 7), + }; + browseBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; + browseBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + browseBorder.MouseLeftButtonUp += (_, _) => + { + FolderMenuPopup.IsOpen = false; + BrowseWorkFolder(); + }; + FolderMenuItems.Children.Add(browseBorder); + + FolderMenuPopup.IsOpen = true; + } + + private void BrowseWorkFolder() + { + var dlg = new System.Windows.Forms.FolderBrowserDialog + { + Description = "작업 폴더를 선택하세요", + ShowNewFolderButton = false, + UseDescriptionForTitle = true, + }; + + var currentFolder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(currentFolder) && System.IO.Directory.Exists(currentFolder)) + dlg.SelectedPath = currentFolder; + + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + if (!IsPathAllowed(dlg.SelectedPath)) + { + CustomMessageBox.Show("이 경로는 작업 폴더로 선택할 수 없습니다.", "경로 제한", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + SetWorkFolder(dlg.SelectedPath); + } + + /// 경로 유효성 검사 — 차단 대상 경로 필터링. + private static bool IsPathAllowed(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + // C:\ 루트 차단 + var normalized = path.TrimEnd('\\', '/'); + if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false; + // "Document" 포함 경로 차단 (대소문자 무시) + if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false; + return true; + } + + private void SetWorkFolder(string path) + { + // 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단 + // 예: "C:\", "D:\", "E:\" 등 + var fullPath = System.IO.Path.GetFullPath(path); + var root = System.IO.Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase)) + { + ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000); + return; + } + + FolderPathLabel.Text = path; + FolderPathLabel.ToolTip = path; + + lock (_convLock) + { + if (_currentConversation != null) + _currentConversation.WorkFolder = path; + } + + // 최근 폴더 목록에 추가 (차단 경로 제외) + var recent = Llm.RecentWorkFolders; + recent.RemoveAll(p => !IsPathAllowed(p)); + recent.Remove(path); + recent.Insert(0, path); + var maxRecent = Math.Clamp(Llm.MaxRecentFolders, 3, 30); + if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent); + Llm.WorkFolder = path; + _settings.Save(); + } + + private string GetCurrentWorkFolder() + { + lock (_convLock) + { + if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder)) + return _currentConversation.WorkFolder; + } + return Llm.WorkFolder; + } + + /// 테마에 맞는 ContextMenu를 생성합니다. + private ContextMenu CreateThemedContextMenu() + { + var bg = ThemeResourceHelper.Background(this); + var border = ThemeResourceHelper.Border(this); + return new ContextMenu + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(4), + }; + } + + /// 최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다. + private void ShowRecentFolderContextMenu(string folderPath) + { + var menu = CreateThemedContextMenu(); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + void AddItem(string icon, string label, Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 12, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; + mi.Click += (_, _) => action(); + menu.Items.Add(mi); + } + + AddItem("\uED25", "폴더 열기", () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = folderPath, + UseShellExecute = true, + }); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + }); + + AddItem("\uE8C8", "경로 복사", () => + { + try { Clipboard.SetText(folderPath); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + menu.Items.Add(new Separator()); + + AddItem("\uE74D", "목록에서 삭제", () => + { + Llm.RecentWorkFolders.RemoveAll( + p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase)); + _settings.Save(); + // 메뉴 새로고침 + if (FolderMenuPopup.IsOpen) + ShowFolderMenu(); + }); + + menu.IsOpen = true; + } + + private void BtnFolderClear_Click(object sender, RoutedEventArgs e) + { + FolderPathLabel.Text = "폴더를 선택하세요"; + FolderPathLabel.ToolTip = null; + lock (_convLock) + { + if (_currentConversation != null) + _currentConversation.WorkFolder = ""; + } + } + + private void UpdateFolderBar() + { + if (FolderBar == null) return; + if (_activeTab == "Chat") + { + FolderBar.Visibility = Visibility.Collapsed; + return; + } + FolderBar.Visibility = Visibility.Visible; + var folder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(folder)) + { + FolderPathLabel.Text = folder; + FolderPathLabel.ToolTip = folder; + } + else + { + FolderPathLabel.Text = "폴더를 선택하세요"; + FolderPathLabel.ToolTip = null; + } + // 대화별 설정 복원 (없으면 전역 기본값) + LoadConversationSettings(); + UpdatePermissionUI(); + UpdateDataUsageUI(); + } + + /// 현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용. + private void LoadConversationSettings() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + var llm = Llm; + + if (conv != null && conv.Permission != null) + Llm.FilePermission = conv.Permission; + + _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "active"; + _selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern"; + } + + /// 현재 하단 바 설정을 대화에 저장합니다. + private void SaveConversationSettings() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + conv.Permission = Llm.FilePermission; + conv.DataUsage = _folderDataUsage; + conv.Mood = _selectedMood; + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index dd22700..4fe1e0d 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -259,4509 +259,4 @@ public partial class ChatWindow : Window _forceClose = true; Close(); } - - // ─── 사용자 정보 ──────────────────────────────────────────────────── - - private void SetupUserInfo() - { - var userName = Environment.UserName; - // AD\, AD/, AD: 접두사 제거 - var cleanName = userName; - foreach (var sep in new[] { '\\', '/', ':' }) - { - var idx = cleanName.LastIndexOf(sep); - if (idx >= 0) cleanName = cleanName[(idx + 1)..]; - } - - var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U"; - var pcName = Environment.MachineName; - - UserInitialSidebar.Text = initial; - UserInitialIconBar.Text = initial; - UserNameText.Text = cleanName; - UserPcText.Text = pcName; - BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})"; - } - - // ─── 스크롤 동작 ────────────────────────────────────────────────── - - private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e) - { - // 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥 - if (MessageScroll.ScrollableHeight <= 1) - { - _userScrolled = false; - return; - } - - // 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지 - if (Math.Abs(e.ExtentHeightChange) > 0.5) - return; - - var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40; - _userScrolled = !atBottom; - } - - private void AutoScrollIfNeeded() - { - if (!_userScrolled) - SmoothScrollToEnd(); - } - - /// 새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋). - private void ForceScrollToEnd() - { - _userScrolled = false; - Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background); - } - - /// 부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다. - private void SmoothScrollToEnd() - { - var targetOffset = MessageScroll.ScrollableHeight; - var currentOffset = MessageScroll.VerticalOffset; - var diff = targetOffset - currentOffset; - - // 차이가 작으면 즉시 이동 (깜빡임 방지) - if (diff <= 60) - { - MessageScroll.ScrollToEnd(); - return; - } - - // 부드럽게 스크롤 (DoubleAnimation) - var animation = new DoubleAnimation - { - From = currentOffset, - To = targetOffset, - Duration = TimeSpan.FromMilliseconds(200), - EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, - }; - animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset); - - // ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간 - var startTime = DateTime.UtcNow; - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps - EventHandler tickHandler = null!; - tickHandler = (_, _) => - { - var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; - var progress = Math.Min(elapsed / 200.0, 1.0); - var eased = 1.0 - Math.Pow(1.0 - progress, 3); - var offset = currentOffset + diff * eased; - MessageScroll.ScrollToVerticalOffset(offset); - - if (progress >= 1.0) - { - timer.Stop(); - timer.Tick -= tickHandler; - } - }; - timer.Tick += tickHandler; - timer.Start(); - } - - // ─── 대화 제목 인라인 편집 ────────────────────────────────────────── - - private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e) - { - lock (_convLock) - { - if (_currentConversation == null) return; - } - ChatTitle.Visibility = Visibility.Collapsed; - ChatTitleEdit.Text = ChatTitle.Text; - ChatTitleEdit.Visibility = Visibility.Visible; - ChatTitleEdit.Focus(); - ChatTitleEdit.SelectAll(); - } - - private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit(); - private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; } - if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; } - } - - private void CommitTitleEdit() - { - var newTitle = ChatTitleEdit.Text.Trim(); - ChatTitleEdit.Visibility = Visibility.Collapsed; - ChatTitle.Visibility = Visibility.Visible; - - if (string.IsNullOrEmpty(newTitle)) return; - - lock (_convLock) - { - if (_currentConversation == null) return; - _currentConversation.Title = newTitle; - } - - ChatTitle.Text = newTitle; - try - { - ChatConversation conv; - lock (_convLock) conv = _currentConversation!; - _storage.Save(conv); - } - catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - RefreshConversationList(); - } - - private void CancelTitleEdit() - { - ChatTitleEdit.Visibility = Visibility.Collapsed; - ChatTitle.Visibility = Visibility.Visible; - } - - // ─── 카테고리 드롭다운 ────────────────────────────────────────────── - - private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e) - { - var borderBrush = ThemeResourceHelper.Border(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var hoverBg = ThemeResourceHelper.HoverBg(this); - var accentBrush = ThemeResourceHelper.Accent(this); - - var (popup, stack) = PopupMenuHelper.Create(BtnCategoryDrop, this, PlacementMode.Bottom, minWidth: 180); - popup.VerticalOffset = 4; - - Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick) - { - var item = 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 g = new Grid(); - g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); - g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); - - var iconTb = new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(iconTb, 0); - g.Children.Add(iconTb); - - var textTb = new TextBlock - { - Text = text, FontSize = 12.5, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(textTb, 1); - g.Children.Add(textTb); - - if (isSelected) - { - var check = CreateSimpleCheck(accentBrush, 14); - Grid.SetColumn(check, 2); - g.Children.Add(check); - } - - item.Child = g; - item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; - item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); }; - return item; - } - - Border CreateSep() => new() - { - Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4), - }; - - // 전체 보기 - var allLabel = _activeTab switch - { - "Cowork" => "모든 작업", - "Code" => "모든 작업", - _ => "모든 주제", - }; - stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText, - string.IsNullOrEmpty(_selectedCategory), - () => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); })); - - stack.Children.Add(CreateSep()); - - if (_activeTab == "Cowork" || _activeTab == "Code") - { - // 코워크/코드: 프리셋 카테고리 기반 필터 - var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); - var seen = new HashSet(); - foreach (var p in presets) - { - if (p.IsCustom) continue; // 커스텀은 별도 그룹 - if (!seen.Add(p.Category)) continue; - var capturedCat = p.Category; - stack.Children.Add(CreateCatItem(p.Symbol, p.Label, BrushFromHex(p.Color), - _selectedCategory == capturedCat, - () => { _selectedCategory = capturedCat; UpdateCategoryLabel(); RefreshConversationList(); })); - } - // 커스텀 프리셋 통합 필터 - if (presets.Any(p => p.IsCustom)) - { - stack.Children.Add(CreateSep()); - stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, - _selectedCategory == "__custom__", - () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); - } - } - else - { - // Chat: 기존 ChatCategory 기반 - foreach (var (key, label, symbol, color) in ChatCategory.All) - { - var capturedKey = key; - stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color), - _selectedCategory == capturedKey, - () => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); })); - } - // 커스텀 프리셋 통합 필터 (Chat) - var chatCustom = Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList(); - if (chatCustom.Count > 0) - { - stack.Children.Add(CreateSep()); - stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, - _selectedCategory == "__custom__", - () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); - } - } - - popup.IsOpen = true; - } - - private void UpdateCategoryLabel() - { - if (string.IsNullOrEmpty(_selectedCategory)) - { - CategoryLabel.Text = _activeTab switch { "Cowork" or "Code" => "모든 작업", _ => "모든 주제" }; - CategoryIcon.Text = "\uE8BD"; - } - else if (_selectedCategory == "__custom__") - { - CategoryLabel.Text = "커스텀 프리셋"; - CategoryIcon.Text = "\uE710"; - } - else - { - // ChatCategory에서 찾기 - foreach (var (key, label, symbol, _) in ChatCategory.All) - { - if (key == _selectedCategory) - { - CategoryLabel.Text = label; - CategoryIcon.Text = symbol; - return; - } - } - // 프리셋 카테고리에서 찾기 (Cowork/Code) - var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); - var match = presets.FirstOrDefault(p => p.Category == _selectedCategory); - if (match != null) - { - CategoryLabel.Text = match.Label; - CategoryIcon.Text = match.Symbol; - } - else - { - CategoryLabel.Text = _selectedCategory; - CategoryIcon.Text = "\uE8BD"; - } - } - } - - // ─── 창 컨트롤 ────────────────────────────────────────────────────── - - // WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요 - - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - var source = System.Windows.Interop.HwndSource.FromHwnd( - new System.Windows.Interop.WindowInteropHelper(this).Handle); - source?.AddHook(WndProc); - } - - private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - // WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보 - if (msg == 0x0024) - { - var screen = System.Windows.Forms.Screen.FromHandle(hwnd); - var workArea = screen.WorkingArea; - var monitor = screen.Bounds; - - var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd); - var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0; - - // MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36) - System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx - System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy - System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x - System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y - handled = true; - } - return IntPtr.Zero; - } - - private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; - private void BtnMaximize_Click(object sender, RoutedEventArgs e) - { - WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘 - } - private void BtnClose_Click(object sender, RoutedEventArgs e) => Close(); - - // ─── 탭 전환 ────────────────────────────────────────────────────────── - - private string _activeTab = "Chat"; - - private void SaveCurrentTabConversationId() - { - lock (_convLock) - { - if (_currentConversation != null && _currentConversation.Messages.Count > 0) - { - _tabConversationId[_activeTab] = _currentConversation.Id; - // 탭 전환 시 현재 대화를 즉시 저장 (스트리밍 중이어도 진행 중인 내용 보존) - try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - } - } - // 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용) - SaveLastConversations(); - } - - /// 탭 전환 전 스트리밍 중이면 즉시 중단합니다. - private void StopStreamingIfActive() - { - if (!_isStreaming) return; - // 스트리밍 중단 - _streamCts?.Cancel(); - _cursorTimer.Stop(); - _elapsedTimer.Stop(); - _typingTimer.Stop(); - StopRainbowGlow(); - HideStickyProgress(); - _activeStreamText = null; - _elapsedLabel = null; - _cachedStreamContent = ""; - _isStreaming = false; - BtnSend.IsEnabled = true; - BtnStop.Visibility = Visibility.Collapsed; - BtnPause.Visibility = Visibility.Collapsed; - PauseIcon.Text = "\uE769"; // 리셋 - BtnSend.Visibility = Visibility.Visible; - _streamCts?.Dispose(); - _streamCts = null; - SetStatusIdle(); - } - - private void TabChat_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Chat") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Chat"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - private void TabCowork_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Cowork") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Cowork"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - private void TabCode_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Code") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Code"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - /// 탭별로 마지막으로 활성화된 대화 ID를 기억. - private readonly Dictionary _tabConversationId = new() - { - ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, - }; - - private void UpdateTabUI() - { - // 폴더 바는 Cowork/Code 탭에서만 표시 - if (FolderBar != null) - FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; - - // 탭별 입력 안내 문구 - if (InputWatermark != null) - { - InputWatermark.Text = _activeTab switch - { - "Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)", - "Code" => "코드 관련 작업을 요청하세요...", - _ => _promptCardPlaceholder, - }; - } - - // 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용) - ApplyTabDefaultPermission(); - - // 포맷/디자인 드롭다운은 Cowork 탭에서만 표시 - if (_activeTab == "Cowork") - { - BuildBottomBar(); - if (Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } - } - else if (_activeTab == "Code") - { - // Code 탭: 언어 선택기 + 파일 탐색기 - BuildCodeBottomBar(); - if (Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } - } - else - { - MoodIconPanel.Children.Clear(); - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; - if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; - } - - // 탭별 프리셋 버튼 재구성 - BuildTopicButtons(); - - // 현재 대화를 해당 탭 대화로 전환 - SwitchToTabConversation(); - - // Cowork/Code 탭 전환 시 팁 표시 - ShowRandomTip(); - } - - private void BtnPlanMode_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - // 3단 순환: off → auto → always → off - Llm.PlanMode = Llm.PlanMode switch - { - "auto" => "always", - "always" => "off", - _ => "auto" - }; - _settings.Save(); - UpdatePlanModeUI(); - } - - private void UpdatePlanModeUI() - { - var planMode = Llm.PlanMode ?? "off"; - if (PlanModeValue == null) return; - - PlanModeValue.Text = planMode switch - { - "auto" => "Auto", - "always" => "Always", - _ => "Off" - }; - var isActive = planMode != "off"; - var activeBrush = ThemeResourceHelper.Accent(this); - var secondaryBrush = ThemeResourceHelper.Secondary(this); - if (PlanModeIcon != null) PlanModeIcon.Foreground = isActive ? activeBrush : secondaryBrush; - if (PlanModeLabel != null) PlanModeLabel.Foreground = isActive ? activeBrush : secondaryBrush; - if (BtnPlanMode != null) - BtnPlanMode.Background = isActive - ? new SolidColorBrush(Color.FromArgb(0x1A, 0x4B, 0x5E, 0xFC)) - : Brushes.Transparent; - } - - private void SwitchToTabConversation() - { - // 이전 탭의 대화 저장 - lock (_convLock) - { - if (_currentConversation != null && _currentConversation.Messages.Count > 0) - { - try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - } - } - - // 현재 탭에 기억된 대화가 있으면 복원 - var savedId = _tabConversationId.GetValueOrDefault(_activeTab); - if (!string.IsNullOrEmpty(savedId)) - { - var conv = _storage.Load(savedId); - if (conv != null) - { - if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; - lock (_convLock) _currentConversation = conv; - MessagePanel.Children.Clear(); - foreach (var msg in conv.Messages) - AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); - EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; - UpdateChatTitle(); - RefreshConversationList(); - UpdateFolderBar(); - return; - } - } - - // 기억된 대화가 없으면 새 대화 - lock (_convLock) - { - _currentConversation = new ChatConversation { Tab = _activeTab }; - var workFolder = Llm.WorkFolder; - if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") - _currentConversation.WorkFolder = workFolder; - } - MessagePanel.Children.Clear(); - EmptyState.Visibility = Visibility.Visible; - _attachedFiles.Clear(); - RefreshAttachedFilesUI(); - UpdateChatTitle(); - RefreshConversationList(); - UpdateFolderBar(); - } - - // ─── 작업 폴더 ───────────────────────────────────────────────────────── - - private readonly List _attachedFiles = new(); - private readonly List _pendingImages = new(); - - private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu(); - - private void ShowFolderMenu() - { - FolderMenuItems.Children.Clear(); - - var accentBrush = ThemeResourceHelper.Accent(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - // 최근 폴더 목록 - var maxDisplay = Math.Clamp(Llm.MaxRecentFolders, 3, 30); - var recentFolders = Llm.RecentWorkFolders - .Where(p => IsPathAllowed(p) && System.IO.Directory.Exists(p)) - .Take(maxDisplay) - .ToList(); - - if (recentFolders.Count > 0) - { - FolderMenuItems.Children.Add(new TextBlock - { - Text = "최근 폴더", - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = secondaryText, - Margin = new Thickness(10, 6, 10, 4), - }); - - var currentFolder = GetCurrentWorkFolder(); - foreach (var folder in recentFolders) - { - var isActive = folder.Equals(currentFolder, StringComparison.OrdinalIgnoreCase); - var displayName = System.IO.Path.GetFileName(folder); - if (string.IsNullOrEmpty(displayName)) displayName = folder; - - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - if (isActive) - { - var checkEl = CreateSimpleCheck(accentBrush, 14); - checkEl.Margin = new Thickness(0, 0, 8, 0); - sp.Children.Add(checkEl); - } - var nameBlock = new TextBlock - { - Text = displayName, - FontSize = 14, - FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - MaxWidth = 340, - TextTrimming = TextTrimming.CharacterEllipsis, - }; - sp.Children.Add(nameBlock); - - var itemBorder = new Border - { - Child = sp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(10, 7, 10, 7), - ToolTip = folder, - }; - itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; - itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - var capturedPath = folder; - itemBorder.MouseLeftButtonUp += (_, _) => - { - FolderMenuPopup.IsOpen = false; - SetWorkFolder(capturedPath); - }; - // 우클릭 → 컨텍스트 메뉴 (삭제, 폴더 열기) - itemBorder.MouseRightButtonUp += (_, re) => - { - re.Handled = true; - ShowRecentFolderContextMenu(capturedPath); - }; - FolderMenuItems.Children.Add(itemBorder); - } - - // 구분선 - FolderMenuItems.Children.Add(new Border - { - Height = 1, - Background = ThemeResourceHelper.Border(this), - Margin = new Thickness(8, 4, 8, 4), - Opacity = 0.5, - }); - } - - // 폴더 찾아보기 버튼 - var browseSp = new StackPanel { Orientation = Orientation.Horizontal }; - browseSp.Children.Add(new TextBlock - { - Text = "\uED25", - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 14, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - browseSp.Children.Add(new TextBlock - { - Text = "폴더 찾아보기...", - FontSize = 14, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var browseBorder = new Border - { - Child = browseSp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(10, 7, 10, 7), - }; - browseBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; - browseBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - browseBorder.MouseLeftButtonUp += (_, _) => - { - FolderMenuPopup.IsOpen = false; - BrowseWorkFolder(); - }; - FolderMenuItems.Children.Add(browseBorder); - - FolderMenuPopup.IsOpen = true; - } - - private void BrowseWorkFolder() - { - var dlg = new System.Windows.Forms.FolderBrowserDialog - { - Description = "작업 폴더를 선택하세요", - ShowNewFolderButton = false, - UseDescriptionForTitle = true, - }; - - var currentFolder = GetCurrentWorkFolder(); - if (!string.IsNullOrEmpty(currentFolder) && System.IO.Directory.Exists(currentFolder)) - dlg.SelectedPath = currentFolder; - - if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; - - if (!IsPathAllowed(dlg.SelectedPath)) - { - CustomMessageBox.Show("이 경로는 작업 폴더로 선택할 수 없습니다.", "경로 제한", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - SetWorkFolder(dlg.SelectedPath); - } - - /// 경로 유효성 검사 — 차단 대상 경로 필터링. - private static bool IsPathAllowed(string path) - { - if (string.IsNullOrWhiteSpace(path)) return false; - // C:\ 루트 차단 - var normalized = path.TrimEnd('\\', '/'); - if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false; - // "Document" 포함 경로 차단 (대소문자 무시) - if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false; - return true; - } - - private void SetWorkFolder(string path) - { - // 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단 - // 예: "C:\", "D:\", "E:\" 등 - var fullPath = System.IO.Path.GetFullPath(path); - var root = System.IO.Path.GetPathRoot(fullPath); - if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase)) - { - ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000); - return; - } - - FolderPathLabel.Text = path; - FolderPathLabel.ToolTip = path; - - lock (_convLock) - { - if (_currentConversation != null) - _currentConversation.WorkFolder = path; - } - - // 최근 폴더 목록에 추가 (차단 경로 제외) - var recent = Llm.RecentWorkFolders; - recent.RemoveAll(p => !IsPathAllowed(p)); - recent.Remove(path); - recent.Insert(0, path); - var maxRecent = Math.Clamp(Llm.MaxRecentFolders, 3, 30); - if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent); - Llm.WorkFolder = path; - _settings.Save(); - } - - private string GetCurrentWorkFolder() - { - lock (_convLock) - { - if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder)) - return _currentConversation.WorkFolder; - } - return Llm.WorkFolder; - } - - /// 테마에 맞는 ContextMenu를 생성합니다. - private ContextMenu CreateThemedContextMenu() - { - var bg = ThemeResourceHelper.Background(this); - var border = ThemeResourceHelper.Border(this); - return new ContextMenu - { - Background = bg, - BorderBrush = border, - BorderThickness = new Thickness(1), - Padding = new Thickness(4), - }; - } - - /// 최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다. - private void ShowRecentFolderContextMenu(string folderPath) - { - var menu = CreateThemedContextMenu(); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - void AddItem(string icon, string label, Action action) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 12, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; - mi.Click += (_, _) => action(); - menu.Items.Add(mi); - } - - AddItem("\uED25", "폴더 열기", () => - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = folderPath, - UseShellExecute = true, - }); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - }); - - AddItem("\uE8C8", "경로 복사", () => - { - try { Clipboard.SetText(folderPath); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - menu.Items.Add(new Separator()); - - AddItem("\uE74D", "목록에서 삭제", () => - { - Llm.RecentWorkFolders.RemoveAll( - p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase)); - _settings.Save(); - // 메뉴 새로고침 - if (FolderMenuPopup.IsOpen) - ShowFolderMenu(); - }); - - menu.IsOpen = true; - } - - private void BtnFolderClear_Click(object sender, RoutedEventArgs e) - { - FolderPathLabel.Text = "폴더를 선택하세요"; - FolderPathLabel.ToolTip = null; - lock (_convLock) - { - if (_currentConversation != null) - _currentConversation.WorkFolder = ""; - } - } - - private void UpdateFolderBar() - { - if (FolderBar == null) return; - if (_activeTab == "Chat") - { - FolderBar.Visibility = Visibility.Collapsed; - return; - } - FolderBar.Visibility = Visibility.Visible; - var folder = GetCurrentWorkFolder(); - if (!string.IsNullOrEmpty(folder)) - { - FolderPathLabel.Text = folder; - FolderPathLabel.ToolTip = folder; - } - else - { - FolderPathLabel.Text = "폴더를 선택하세요"; - FolderPathLabel.ToolTip = null; - } - // 대화별 설정 복원 (없으면 전역 기본값) - LoadConversationSettings(); - UpdatePermissionUI(); - UpdateDataUsageUI(); - } - - /// 현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용. - private void LoadConversationSettings() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - var llm = Llm; - - if (conv != null && conv.Permission != null) - Llm.FilePermission = conv.Permission; - - _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "active"; - _selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern"; - } - - /// 현재 하단 바 설정을 대화에 저장합니다. - private void SaveConversationSettings() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - conv.Permission = Llm.FilePermission; - conv.DataUsage = _folderDataUsage; - conv.Mood = _selectedMood; - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - } - - // ─── 권한 메뉴 ───────────────────────────────────────────────────────── - - private void BtnPermission_Click(object sender, RoutedEventArgs e) - { - if (PermissionPopup == null) return; - PermissionItems.Children.Clear(); - - var levels = new (string Level, string Sym, string Desc, string Color)[] { - ("Ask", "\uE8D7", "매번 확인 — 파일 접근 시 사용자에게 묻습니다", "#4B5EFC"), - ("Auto", "\uE73E", "자동 허용 — 파일을 자동으로 읽고 씁니다", "#DD6B20"), - ("Deny", "\uE711", "접근 차단 — 파일 접근을 허용하지 않습니다", "#C50F1F"), - }; - var current = Llm.FilePermission; - foreach (var (level, sym, desc, color) in levels) - { - var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); - - // 라운드 코너 템플릿 (기본 Button 크롬 제거) - var template = new ControlTemplate(typeof(Button)); - var bdFactory = new FrameworkElementFactory(typeof(Border)); - bdFactory.SetValue(Border.BackgroundProperty, Brushes.Transparent); - bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); - bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 8, 12, 8)); - bdFactory.Name = "Bd"; - var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter)); - cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Left); - cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); - bdFactory.AppendChild(cpFactory); - template.VisualTree = bdFactory; - // 호버 효과 - var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; - hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, - new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)), "Bd")); - template.Triggers.Add(hoverTrigger); - - var btn = new Button - { - Template = template, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - HorizontalContentAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 0, 1), - }; - ApplyHoverScaleAnimation(btn, 1.02); - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive)); - sp.Children.Add(new TextBlock - { - Text = sym, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 14, - Foreground = BrushFromHex(color), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), - }); - var textStack = new StackPanel(); - textStack.Children.Add(new TextBlock - { - Text = level, FontSize = 13, FontWeight = FontWeights.Bold, - Foreground = BrushFromHex(color), - }); - textStack.Children.Add(new TextBlock - { - Text = desc, FontSize = 11, - Foreground = ThemeResourceHelper.Secondary(this), - TextWrapping = TextWrapping.Wrap, - MaxWidth = 220, - }); - sp.Children.Add(textStack); - btn.Content = sp; - - var capturedLevel = level; - btn.Click += (_, _) => - { - Llm.FilePermission = capturedLevel; - UpdatePermissionUI(); - SaveConversationSettings(); - PermissionPopup.IsOpen = false; - }; - PermissionItems.Children.Add(btn); - } - PermissionPopup.IsOpen = true; - } - - private bool _autoWarningDismissed; // Auto 경고 배너 사용자가 닫았는지 - - private void BtnAutoWarningClose_Click(object sender, RoutedEventArgs e) - { - _autoWarningDismissed = true; - if (AutoPermissionWarning != null) - AutoPermissionWarning.Visibility = Visibility.Collapsed; - } - - private void UpdatePermissionUI() - { - if (PermissionLabel == null || PermissionIcon == null) return; - var perm = Llm.FilePermission; - PermissionLabel.Text = perm; - PermissionIcon.Text = perm switch - { - "Auto" => "\uE73E", - "Deny" => "\uE711", - _ => "\uE8D7", - }; - - // Auto 모드일 때 경고 색상 + 배너 표시 - if (perm == "Auto") - { - var warnColor = new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20)); - PermissionLabel.Foreground = warnColor; - PermissionIcon.Foreground = warnColor; - // Auto 전환 시 새 대화에서만 1회 필수 표시 (기존 대화에서 이미 Auto였으면 숨김) - ChatConversation? convForWarn; - lock (_convLock) convForWarn = _currentConversation; - var isExisting = convForWarn != null && convForWarn.Messages.Count > 0 && convForWarn.Permission == "Auto"; - if (AutoPermissionWarning != null && !_autoWarningDismissed && !isExisting) - AutoPermissionWarning.Visibility = Visibility.Visible; - } - else - { - _autoWarningDismissed = false; // Auto가 아닌 모드로 전환하면 리셋 - var defaultFg = ThemeResourceHelper.Secondary(this); - var iconFg = perm == "Deny" ? new SolidColorBrush(Color.FromRgb(0xC5, 0x0F, 0x1F)) - : new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); // Ask = 파란색 - PermissionLabel.Foreground = defaultFg; - PermissionIcon.Foreground = iconFg; - if (AutoPermissionWarning != null) - AutoPermissionWarning.Visibility = Visibility.Collapsed; - } - } - - // ──── 데이터 활용 수준 메뉴 ──── - - private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (DataUsagePopup == null) return; - DataUsageItems.Children.Clear(); - - var options = new (string Key, string Sym, string Label, string Desc, string Color)[] - { - ("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10"), - ("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706"), - ("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽거나 참조하지 않습니다", "#9CA3AF"), - }; - - foreach (var (key, sym, label, desc, color) in options) - { - var isActive = key.Equals(_folderDataUsage, StringComparison.OrdinalIgnoreCase); - - var template = new ControlTemplate(typeof(Button)); - var bdFactory = new FrameworkElementFactory(typeof(Border)); - bdFactory.SetValue(Border.BackgroundProperty, Brushes.Transparent); - bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); - bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 8, 12, 8)); - bdFactory.Name = "Bd"; - var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter)); - cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Left); - cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); - bdFactory.AppendChild(cpFactory); - template.VisualTree = bdFactory; - var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; - hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, - new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)), "Bd")); - template.Triggers.Add(hoverTrigger); - - var btn = new Button - { - Template = template, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - HorizontalContentAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 0, 1), - }; - ApplyHoverScaleAnimation(btn, 1.02); - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive)); - sp.Children.Add(new TextBlock - { - Text = sym, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 14, - Foreground = BrushFromHex(color), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), - }); - var textStack = new StackPanel(); - textStack.Children.Add(new TextBlock - { - Text = label, FontSize = 13, FontWeight = FontWeights.Bold, - Foreground = BrushFromHex(color), - }); - textStack.Children.Add(new TextBlock - { - Text = desc, FontSize = 11, - Foreground = ThemeResourceHelper.Secondary(this), - TextWrapping = TextWrapping.Wrap, - MaxWidth = 240, - }); - sp.Children.Add(textStack); - btn.Content = sp; - - var capturedKey = key; - btn.Click += (_, _) => - { - _folderDataUsage = capturedKey; - UpdateDataUsageUI(); - SaveConversationSettings(); - DataUsagePopup.IsOpen = false; - }; - DataUsageItems.Children.Add(btn); - } - DataUsagePopup.IsOpen = true; - } - - private void UpdateDataUsageUI() - { - if (DataUsageLabel == null || DataUsageIcon == null) return; - var (label, icon, color) = _folderDataUsage switch - { - "passive" => ("소극", "\uE8FD", "#D97706"), - "none" => ("미사용", "\uE8D8", "#9CA3AF"), - _ => ("적극", "\uE9F5", "#107C10"), - }; - DataUsageLabel.Text = label; - DataUsageIcon.Text = icon; - DataUsageIcon.Foreground = BrushFromHex(color); - } - - /// Cowork/Code 탭 진입 시 설정의 기본 권한을 적용. - private void ApplyTabDefaultPermission() - { - if (_activeTab == "Chat") - { - // Chat 탭: 경고 배너 숨기고 기본 Ask 모드로 복원 - Llm.FilePermission = "Ask"; - UpdatePermissionUI(); - return; - } - var defaultPerm = Llm.DefaultAgentPermission; - if (!string.IsNullOrEmpty(defaultPerm)) - { - Llm.FilePermission = defaultPerm; - UpdatePermissionUI(); - } - } - - // ─── 파일 첨부 ───────────────────────────────────────────────────────── - - private void BtnAttach_Click(object sender, RoutedEventArgs e) - { - var dlg = new Microsoft.Win32.OpenFileDialog - { - Multiselect = true, - Title = "첨부할 파일을 선택하세요", - Filter = "모든 파일 (*.*)|*.*|텍스트 (*.txt;*.md;*.csv)|*.txt;*.md;*.csv|코드 (*.cs;*.py;*.js;*.ts)|*.cs;*.py;*.js;*.ts", - }; - - // 작업 폴더가 있으면 초기 경로 설정 - var workFolder = GetCurrentWorkFolder(); - if (!string.IsNullOrEmpty(workFolder) && System.IO.Directory.Exists(workFolder)) - dlg.InitialDirectory = workFolder; - - if (dlg.ShowDialog() != true) return; - - foreach (var file in dlg.FileNames) - AddAttachedFile(file); - } - - private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" - }; - - private void AddAttachedFile(string filePath) - { - if (_attachedFiles.Contains(filePath)) return; - - // 파일 크기 제한 (10MB) - try - { - var fi = new System.IO.FileInfo(filePath); - if (fi.Length > 10 * 1024 * 1024) - { - CustomMessageBox.Show($"파일이 너무 큽니다 (10MB 초과):\n{fi.Name}", "첨부 제한", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - // 이미지 파일 → Vision API용 base64 변환 - var ext = fi.Extension.ToLowerInvariant(); - if (ImageExtensions.Contains(ext) && Llm.EnableImageInput) - { - var maxKb = Llm.MaxImageSizeKb; - if (maxKb <= 0) maxKb = 5120; - if (fi.Length > maxKb * 1024) - { - CustomMessageBox.Show($"이미지가 너무 큽니다 ({fi.Length / 1024}KB, 최대 {maxKb}KB).", - "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - var bytes = System.IO.File.ReadAllBytes(filePath); - var mimeType = ext switch - { - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".bmp" => "image/bmp", - ".webp" => "image/webp", - _ => "image/png", - }; - var attachment = new ImageAttachment - { - Base64 = Convert.ToBase64String(bytes), - MimeType = mimeType, - FileName = fi.Name, - }; - - // 중복 확인 - if (_pendingImages.Any(i => i.FileName == attachment.FileName)) return; - - _pendingImages.Add(attachment); - AddImagePreview(attachment); - return; - } - } - catch (Exception) { return; } - - _attachedFiles.Add(filePath); - RefreshAttachedFilesUI(); - } - - private void RemoveAttachedFile(string filePath) - { - _attachedFiles.Remove(filePath); - RefreshAttachedFilesUI(); - } - - private void RefreshAttachedFilesUI() - { - AttachedFilesPanel.Items.Clear(); - if (_attachedFiles.Count == 0) - { - AttachedFilesPanel.Visibility = Visibility.Collapsed; - return; - } - - AttachedFilesPanel.Visibility = Visibility.Visible; - var secondaryBrush = ThemeResourceHelper.Secondary(this); - var hintBg = ThemeResourceHelper.Hint(this); - - foreach (var file in _attachedFiles.ToList()) - { - var fileName = System.IO.Path.GetFileName(file); - var capturedFile = file; - - var chip = new Border - { - Background = hintBg, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(8, 4, 4, 4), - Margin = new Thickness(0, 0, 4, 4), - Cursor = Cursors.Hand, - }; - - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = "\uE8A5", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 10, - Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), - }); - sp.Children.Add(new TextBlock - { - Text = fileName, FontSize = 11, Foreground = secondaryBrush, - VerticalAlignment = VerticalAlignment.Center, MaxWidth = 150, TextTrimming = TextTrimming.CharacterEllipsis, - ToolTip = file, - }); - var removeBtn = new Button - { - Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush }, - Background = Brushes.Transparent, BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), - }; - removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile); - sp.Children.Add(removeBtn); - chip.Child = sp; - AttachedFilesPanel.Items.Add(chip); - } - } - - /// 첨부 파일 내용을 시스템 메시지로 변환합니다. - private string BuildFileContextPrompt() - { - if (_attachedFiles.Count == 0) return ""; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("\n[첨부 파일 컨텍스트]"); - - foreach (var file in _attachedFiles) - { - try - { - var ext = System.IO.Path.GetExtension(file).ToLowerInvariant(); - var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz" - or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg" - or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt" - or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac" - or ".psd" or ".ai" or ".sketch" or ".fig" - or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite"; - if (isBinary) - { - sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---"); - continue; - } - - var content = System.IO.File.ReadAllText(file); - // 최대 8000자로 제한 - if (content.Length > 8000) - content = content[..8000] + "\n... (이하 생략)"; - - sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---"); - sb.AppendLine(content); - } - catch (Exception ex) - { - sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---"); - } - } - - return sb.ToString(); - } - - private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e) - { - var newW = Width + e.HorizontalChange; - var newH = Height + e.VerticalChange; - if (newW >= MinWidth) Width = newW; - if (newH >= MinHeight) Height = newH; - } - - // ─── 사이드바 토글 ─────────────────────────────────────────────────── - - private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e) - { - _sidebarVisible = !_sidebarVisible; - if (_sidebarVisible) - { - // 사이드바 열기, 아이콘 바 숨기기 - IconBarColumn.Width = new GridLength(0); - IconBarPanel.Visibility = Visibility.Collapsed; - SidebarPanel.Visibility = Visibility.Visible; - ToggleSidebarIcon.Text = "\uE76B"; - AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200); - } - else - { - // 사이드바 닫기, 아이콘 바 표시 - SidebarColumn.MinWidth = 0; - ToggleSidebarIcon.Text = "\uE76C"; - AnimateSidebar(270, 0, () => - { - SidebarPanel.Visibility = Visibility.Collapsed; - IconBarColumn.Width = new GridLength(52); - IconBarPanel.Visibility = Visibility.Visible; - }); - } - } - - private void AnimateSidebar(double from, double to, Action? onComplete = null) - { - var duration = 200.0; - var start = DateTime.UtcNow; - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; - EventHandler tickHandler = null!; - tickHandler = (_, _) => - { - var elapsed = (DateTime.UtcNow - start).TotalMilliseconds; - var t = Math.Min(elapsed / duration, 1.0); - t = 1 - (1 - t) * (1 - t); - SidebarColumn.Width = new GridLength(from + (to - from) * t); - if (elapsed >= duration) - { - timer.Stop(); - timer.Tick -= tickHandler; - SidebarColumn.Width = new GridLength(to); - onComplete?.Invoke(); - } - }; - timer.Tick += tickHandler; - timer.Start(); - } - - // ─── 대화 목록 ──────────────────────────────────────────────────────── - - public void RefreshConversationList() - { - var metas = _storage.LoadAllMeta(); - // 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원) - var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", Llm.CustomPresets) - .Concat(Services.PresetService.GetByTabWithCustom("Code", Llm.CustomPresets)) - .Concat(Services.PresetService.GetByTabWithCustom("Chat", Llm.CustomPresets)); - var presetMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in allPresets) - presetMap.TryAdd(p.Category, (p.Symbol, p.Color)); - - var items = metas.Select(c => - { - var symbol = ChatCategory.GetSymbol(c.Category); - var color = ChatCategory.GetColor(c.Category); - // ChatCategory 기본값이면 프리셋에서 검색 - if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General) - { - if (presetMap.TryGetValue(c.Category, out var pm)) - { - symbol = pm.Symbol; - color = pm.Color; - } - } - return new ConversationMeta - { - Id = c.Id, - Title = c.Title, - Pinned = c.Pinned, - Category = c.Category, - Symbol = symbol, - ColorHex = color, - Tab = string.IsNullOrEmpty(c.Tab) ? "Chat" : c.Tab, - UpdatedAtText = FormatDate(c.UpdatedAt), - UpdatedAt = c.UpdatedAt, - Preview = c.Preview ?? "", - ParentId = c.ParentId, - }; - }).ToList(); - - // 탭 필터 — 현재 활성 탭의 대화만 표시 - items = items.Where(i => i.Tab == _activeTab).ToList(); - - // 카테고리 필터 적용 - if (_selectedCategory == "__custom__") - { - // 커스텀 프리셋으로 만든 대화만 표시 - var customCats = Llm.CustomPresets - .Select(c => $"custom_{c.Id}").ToHashSet(); - items = items.Where(i => customCats.Contains(i.Category)).ToList(); - } - else if (!string.IsNullOrEmpty(_selectedCategory)) - items = items.Where(i => i.Category == _selectedCategory).ToList(); - - // 검색 필터 (제목 + 내용 미리보기) - var search = SearchBox?.Text?.Trim() ?? ""; - if (!string.IsNullOrEmpty(search)) - items = items.Where(i => - i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || - i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) - ).ToList(); - - RenderConversationList(items); - } - - private const int ConversationPageSize = 50; - private List? _pendingConversations; - - private void RenderConversationList(List items) - { - ConversationPanel.Children.Clear(); - _pendingConversations = null; - - if (items.Count == 0) - { - var empty = new TextBlock - { - Text = "대화가 없습니다", - FontSize = 12, - Foreground = (ThemeResourceHelper.Secondary(this)), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 20, 0, 0) - }; - ConversationPanel.Children.Add(empty); - return; - } - - // 오늘 / 이전 그룹 분리 - var today = DateTime.Today; - var todayItems = items.Where(i => i.UpdatedAt.Date == today).ToList(); - var olderItems = items.Where(i => i.UpdatedAt.Date < today).ToList(); - - var allOrdered = new List<(string Group, ConversationMeta Item)>(); - foreach (var item in todayItems) allOrdered.Add(("오늘", item)); - foreach (var item in olderItems) allOrdered.Add(("이전", item)); - - // 첫 페이지만 렌더링 - var firstPage = allOrdered.Take(ConversationPageSize).ToList(); - string? lastGroup = null; - foreach (var (group, item) in firstPage) - { - if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; } - AddConversationItem(item); - } - - // 나머지가 있으면 "더 보기" 버튼 - if (allOrdered.Count > ConversationPageSize) - { - _pendingConversations = items; - AddLoadMoreButton(allOrdered.Count - ConversationPageSize); - } - } - - private void AddLoadMoreButton(int remaining) - { - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var btn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(8, 10, 8, 10), - Margin = new Thickness(6, 4, 6, 4), - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; - sp.Children.Add(new TextBlock - { - Text = $"더 보기 ({remaining}개 남음)", - FontSize = 12, - Foreground = accentBrush, - HorizontalAlignment = HorizontalAlignment.Center, - }); - btn.Child = sp; - btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; - btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - btn.MouseLeftButtonUp += (_, _) => - { - // 전체 목록 렌더링 - if (_pendingConversations != null) - { - var all = _pendingConversations; - _pendingConversations = null; - ConversationPanel.Children.Clear(); - - var today = DateTime.Today; - var todayItems = all.Where(i => i.UpdatedAt.Date == today).ToList(); - var olderItems = all.Where(i => i.UpdatedAt.Date < today).ToList(); - - if (todayItems.Count > 0) - { - AddGroupHeader("오늘"); - foreach (var item in todayItems) AddConversationItem(item); - } - if (olderItems.Count > 0) - { - AddGroupHeader("이전"); - foreach (var item in olderItems) AddConversationItem(item); - } - } - }; - ConversationPanel.Children.Add(btn); - } - - private void AddGroupHeader(string text) - { - var header = new TextBlock - { - Text = text, - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = (ThemeResourceHelper.Secondary(this)), - Margin = new Thickness(8, 12, 0, 4) - }; - ConversationPanel.Children.Add(header); - } - - private void AddConversationItem(ConversationMeta item) - { - var isSelected = false; - lock (_convLock) - isSelected = _currentConversation?.Id == item.Id; - - var isBranch = !string.IsNullOrEmpty(item.ParentId); - var border = new Border - { - Background = isSelected - ? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)) - : Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = isBranch ? new Thickness(16, 1, 0, 1) : new Thickness(0, 1, 0, 1), - Cursor = Cursors.Hand - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - // 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상) - Brush iconBrush; - if (item.Pinned) - iconBrush = Brushes.Orange; - else - { - try { iconBrush = ThemeResourceHelper.HexBrush(item.ColorHex); } - catch (Exception) { iconBrush = ThemeResourceHelper.Accent(this); } - } - var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol; - if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색 - var icon = new TextBlock - { - Text = iconText, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, - Foreground = iconBrush, - VerticalAlignment = VerticalAlignment.Center - }; - Grid.SetColumn(icon, 0); - grid.Children.Add(icon); - - // 제목 + 날짜 (선택 시 약간 밝게) - var titleColor = ThemeResourceHelper.Primary(this); - var dateColor = ThemeResourceHelper.HintFg(this); - - var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - var title = new TextBlock - { - Text = item.Title, - FontSize = 12.5, - Foreground = titleColor, - TextTrimming = TextTrimming.CharacterEllipsis - }; - var date = new TextBlock - { - Text = item.UpdatedAtText, - FontSize = 10, - Foreground = dateColor, - Margin = new Thickness(0, 2, 0, 0) - }; - stack.Children.Add(title); - stack.Children.Add(date); - Grid.SetColumn(stack, 1); - grid.Children.Add(stack); - - // 카테고리 변경 버튼 (호버 시 표시) - var catBtn = new Button - { - Content = new TextBlock - { - Text = "\uE70F", // Edit - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, - Foreground = (ThemeResourceHelper.Secondary(this)) - }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, - Visibility = Visibility.Collapsed, - Padding = new Thickness(4), - ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경" - }; - var capturedId = item.Id; - catBtn.Click += (_, _) => ShowConversationMenu(capturedId); - Grid.SetColumn(catBtn, 2); - grid.Children.Add(catBtn); - - // 선택 시 좌측 액센트 바 - if (isSelected) - { - border.BorderBrush = ThemeResourceHelper.Accent(this); - border.BorderThickness = new Thickness(2, 0, 0, 0); - } - - border.Child = grid; - - // 호버 이벤트 — 배경 + 미세 확대 - border.RenderTransformOrigin = new Point(0.5, 0.5); - border.RenderTransform = new ScaleTransform(1, 1); - var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)); - border.MouseEnter += (_, _) => - { - if (!isSelected) - border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); - catBtn.Visibility = Visibility.Visible; - var st = border.RenderTransform as ScaleTransform; - st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); - st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); - }; - border.MouseLeave += (_, _) => - { - if (!isSelected) - border.Background = Brushes.Transparent; - catBtn.Visibility = Visibility.Collapsed; - var st = border.RenderTransform as ScaleTransform; - st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); - st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); - }; - - // 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환 - border.MouseLeftButtonDown += (_, _) => - { - try - { - if (isSelected) - { - // 이미 선택된 대화 → 제목 편집 모드 - EnterTitleEditMode(title, item.Id, titleColor); - return; - } - // 스트리밍 중이면 취소 - if (_isStreaming) - { - _streamCts?.Cancel(); - _cursorTimer.Stop(); - _typingTimer.Stop(); - _elapsedTimer.Stop(); - _activeStreamText = null; - _elapsedLabel = null; - _isStreaming = false; - } - var conv = _storage.Load(item.Id); - if (conv != null) - { - // Tab 보정 - if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; - lock (_convLock) _currentConversation = conv; - _tabConversationId[_activeTab] = conv.Id; - UpdateChatTitle(); - RenderMessages(); - RefreshConversationList(); - } - } - catch (Exception ex) - { - LogService.Error($"대화 전환 오류: {ex.Message}"); - } - }; - - // 우클릭 → 대화 관리 메뉴 바로 표시 - border.MouseRightButtonUp += (_, me) => - { - me.Handled = true; - // 선택되지 않은 대화를 우클릭하면 먼저 선택 - if (!isSelected) - { - var conv = _storage.Load(item.Id); - if (conv != null) - { - if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; - lock (_convLock) _currentConversation = conv; - _tabConversationId[_activeTab] = conv.Id; - UpdateChatTitle(); - RenderMessages(); - } - } - // Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기 - Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input); - }; - - 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) - { - RefreshConversationList(); - } - - private static string FormatDate(DateTime dt) - { - var diff = DateTime.Now - dt; - if (diff.TotalMinutes < 1) return "방금 전"; - if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전"; - if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전"; - if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전"; - return dt.ToString("MM/dd"); - } - - private void UpdateChatTitle() - { - lock (_convLock) - { - ChatTitle.Text = _currentConversation?.Title ?? ""; - } - } - - // ─── 메시지 렌더링 → ChatWindow.MessageRendering.cs ───────────────── - - // ─── 메시지 등장 애니메이션 ────────────────────────────────────────── - - private static void ApplyMessageEntryAnimation(FrameworkElement element) - { - element.Opacity = 0; - element.RenderTransform = new TranslateTransform(0, 16); - element.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) - { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); - ((TranslateTransform)element.RenderTransform).BeginAnimation( - TranslateTransform.YProperty, - new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) - { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); - } - - // ─── 메시지 편집 ────────────────────────────────────────────────────── - - private bool _isEditing; // 편집 모드 중복 방지 - - private void EnterEditMode(StackPanel wrapper, string originalText) - { - if (_isStreaming || _isEditing) return; - _isEditing = true; - - // wrapper 위치(인덱스) 기억 - var idx = MessagePanel.Children.IndexOf(wrapper); - if (idx < 0) { _isEditing = false; return; } - - // 편집 UI 생성 - var editPanel = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Right, - MaxWidth = 540, - Margin = wrapper.Margin, - }; - - var editBox = new TextBox - { - Text = originalText, - FontSize = 13.5, - Foreground = ThemeResourceHelper.Primary(this), - Background = ThemeResourceHelper.ItemBg(this), - CaretBrush = ThemeResourceHelper.Accent(this), - BorderBrush = ThemeResourceHelper.Accent(this), - BorderThickness = new Thickness(1.5), - Padding = new Thickness(14, 10, 14, 10), - TextWrapping = TextWrapping.Wrap, - AcceptsReturn = false, - MaxHeight = 200, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - }; - // 둥근 모서리 - var editBorder = new Border - { - CornerRadius = new CornerRadius(14), - Child = editBox, - ClipToBounds = true, - }; - editPanel.Children.Add(editBorder); - - // 버튼 바 - var btnBar = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 6, 0, 0), - }; - - var accentBrush = ThemeResourceHelper.Accent(this); - var secondaryBrush = ThemeResourceHelper.Secondary(this); - - // 취소 버튼 - var cancelBtn = new Button - { - Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - Padding = new Thickness(12, 5, 12, 5), - Margin = new Thickness(0, 0, 6, 0), - }; - cancelBtn.Click += (_, _) => - { - _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) - MessagePanel.Children[idx] = wrapper; // 원래 버블 복원 - }; - btnBar.Children.Add(cancelBtn); - - // 전송 버튼 - var sendBtn = new Button - { - Cursor = Cursors.Hand, - Padding = new Thickness(0), - }; - sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( - "" + - "" + - "" + - ""); - sendBtn.Click += (_, _) => - { - var newText = editBox.Text.Trim(); - if (!string.IsNullOrEmpty(newText)) - _ = SubmitEditAsync(idx, newText); - }; - btnBar.Children.Add(sendBtn); - - editPanel.Children.Add(btnBar); - - // 기존 wrapper → editPanel 교체 - MessagePanel.Children[idx] = editPanel; - - // Enter 키로도 전송 - editBox.KeyDown += (_, ke) => - { - if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) - { - ke.Handled = true; - var newText = editBox.Text.Trim(); - if (!string.IsNullOrEmpty(newText)) - _ = SubmitEditAsync(idx, newText); - } - if (ke.Key == Key.Escape) - { - ke.Handled = true; - _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) - MessagePanel.Children[idx] = wrapper; - } - }; - - editBox.Focus(); - editBox.SelectAll(); - } - - private async Task SubmitEditAsync(int bubbleIndex, string newText) - { - _isEditing = false; - if (_isStreaming) return; - - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // bubbleIndex에 해당하는 user 메시지 찾기 - // UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨 - // 데이터 모델에서 해당 위치의 user 메시지 찾기 - int userMsgIdx = -1; - int uiIdx = 0; - lock (_convLock) - { - for (int i = 0; i < conv.Messages.Count; i++) - { - if (conv.Messages[i].Role == "system") continue; - if (uiIdx == bubbleIndex) - { - userMsgIdx = i; - break; - } - uiIdx++; - } - } - - if (userMsgIdx < 0) return; - - // 데이터 모델에서 편집된 메시지 이후 모두 제거 - lock (_convLock) - { - conv.Messages[userMsgIdx].Content = newText; - while (conv.Messages.Count > userMsgIdx + 1) - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - - // UI에서 편집된 버블 이후 모두 제거 - while (MessagePanel.Children.Count > bubbleIndex + 1) - MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); - - // 편집된 메시지를 새 버블로 교체 - MessagePanel.Children.RemoveAt(bubbleIndex); - AddMessageBubble("user", newText, animate: false); - - // 마지막 위치에 삽입되도록 조정 (AddMessageBubble은 끝에 추가됨) - // bubbleIndex가 끝이 아니면 이동 — 이 경우 이후가 다 제거되었으므로 끝에 추가됨 - - // AI 재응답 - await SendRegenerateAsync(conv); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RefreshConversationList(); - } - - // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── - - private void StopAiIconPulse() - { - if (_aiIconPulseStopped || _activeAiIcon == null) return; - _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null); - _activeAiIcon.Opacity = 1.0; - _activeAiIcon = null; - _aiIconPulseStopped = true; - } - - private void CursorTimer_Tick(object? sender, EventArgs e) - { - _cursorVisible = !_cursorVisible; - // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당 - if (_activeStreamText != null && _displayedLength > 0) - { - var displayed = _cachedStreamContent.Length > 0 - ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)] - : ""; - _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); - } - } - - private void ElapsedTimer_Tick(object? sender, EventArgs e) - { - var elapsed = DateTime.UtcNow - _streamStartTime; - var sec = (int)elapsed.TotalSeconds; - if (_elapsedLabel != null) - _elapsedLabel.Text = $"{sec}s"; - - // 하단 상태바 시간 갱신 - if (StatusElapsed != null) - StatusElapsed.Text = $"{sec}초"; - } - - private void TypingTimer_Tick(object? sender, EventArgs e) - { - if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return; - - var targetLen = _cachedStreamContent.Length; - if (_displayedLength >= targetLen) return; - - // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응 - var pending = targetLen - _displayedLength; - int step; - if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기 - else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속 - else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자 - - _displayedLength += step; - - var displayed = _cachedStreamContent[.._displayedLength]; - _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); - - // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발) - if (!_userScrolled) - MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); - } - - // ─── 전송 ────────────────────────────────────────────────────────────── - - public void SendInitialMessage(string message) - { - StartNewConversation(); - InputBox.Text = message; - _ = SendMessageAsync(); - } - - private void StartNewConversation() - { - // 현재 대화가 있으면 저장 후 새 대화 시작 - lock (_convLock) - { - if (_currentConversation != null && _currentConversation.Messages.Count > 0) - try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - _currentConversation = new ChatConversation { Tab = _activeTab }; - // 작업 폴더가 설정에 있으면 새 대화에 자동 연결 (Cowork/Code 탭) - var workFolder = Llm.WorkFolder; - if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") - _currentConversation.WorkFolder = workFolder; - } - // 탭 기억 초기화 (새 대화이므로) - _tabConversationId[_activeTab] = null; - MessagePanel.Children.Clear(); - EmptyState.Visibility = Visibility.Visible; - _attachedFiles.Clear(); - RefreshAttachedFilesUI(); - UpdateChatTitle(); - RefreshConversationList(); - UpdateFolderBar(); - if (_activeTab == "Cowork") BuildBottomBar(); - } - - /// 설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다. - private void RestoreLastConversations() - { - var saved = Llm.LastConversationIds; - if (saved == null || saved.Count == 0) return; - - foreach (var kv in saved) - { - if (_tabConversationId.ContainsKey(kv.Key) && !string.IsNullOrEmpty(kv.Value)) - _tabConversationId[kv.Key] = kv.Value; - } - - // 현재 활성 탭의 대화를 즉시 로드 - var currentTabId = _tabConversationId.GetValueOrDefault(_activeTab); - if (!string.IsNullOrEmpty(currentTabId)) - { - var conv = _storage.Load(currentTabId); - if (conv != null) - { - if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; - lock (_convLock) _currentConversation = conv; - MessagePanel.Children.Clear(); - foreach (var msg in conv.Messages) - AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); - EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; - UpdateChatTitle(); - UpdateFolderBar(); - LoadConversationSettings(); - } - } - } - - /// 현재 _tabConversationId를 설정에 저장합니다. - private void SaveLastConversations() - { - var dict = new Dictionary(); - foreach (var kv in _tabConversationId) - { - if (!string.IsNullOrEmpty(kv.Value)) - dict[kv.Key] = kv.Value; - } - Llm.LastConversationIds = dict; - try { _settings.Save(); } catch (Exception) { /* 설정 저장 실패 */ } - } - - private async void BtnSend_Click(object sender, RoutedEventArgs e) => await SendMessageAsync(); - - private async void InputBox_PreviewKeyDown(object sender, KeyEventArgs e) - { - // Ctrl+V: 클립보드 이미지 붙여넣기 - if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) - { - if (TryPasteClipboardImage()) - { - e.Handled = true; - return; - } - // 이미지가 아니면 기본 텍스트 붙여넣기로 위임 - } - - if (e.Key == Key.Enter) - { - if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) - { - // Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임) - return; - } - - // 슬래시 팝업이 열려 있으면 선택된 항목 실행 - if (SlashPopup.IsOpen && _slashSelectedIndex >= 0) - { - e.Handled = true; - ExecuteSlashSelectedItem(); - return; - } - - // /help 직접 입력 시 도움말 창 표시 - if (InputBox.Text.Trim().Equals("/help", StringComparison.OrdinalIgnoreCase)) - { - e.Handled = true; - InputBox.Text = ""; - SlashPopup.IsOpen = false; - ShowSlashHelpWindow(); - return; - } - - // Enter만 → 메시지 전송 - e.Handled = true; - await SendMessageAsync(); - } - } - - /// 클립보드에 이미지가 있으면 붙여넣기. 성공 시 true. - private bool TryPasteClipboardImage() - { - if (!Llm.EnableImageInput) return false; - if (!Clipboard.ContainsImage()) return false; - - try - { - var img = Clipboard.GetImage(); - if (img == null) return false; - - // base64 인코딩 - var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); - encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(img)); - using var ms = new System.IO.MemoryStream(); - encoder.Save(ms); - var bytes = ms.ToArray(); - - // 크기 제한 확인 - var maxKb = Llm.MaxImageSizeKb; - if (maxKb <= 0) maxKb = 5120; - if (bytes.Length > maxKb * 1024) - { - CustomMessageBox.Show($"이미지가 너무 큽니다 ({bytes.Length / 1024}KB, 최대 {maxKb}KB).", - "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); - return true; // 처리됨 (에러이지만 이미지였음) - } - - var base64 = Convert.ToBase64String(bytes); - var attachment = new ImageAttachment - { - Base64 = base64, - MimeType = "image/png", - FileName = $"clipboard_{DateTime.Now:HHmmss}.png", - }; - - _pendingImages.Add(attachment); - AddImagePreview(attachment, img); - return true; - } - catch (Exception ex) - { - Services.LogService.Debug($"클립보드 이미지 붙여넣기 실패: {ex.Message}"); - return false; - } - } - - /// 이미지 미리보기 UI 추가. - private void AddImagePreview(ImageAttachment attachment, System.Windows.Media.Imaging.BitmapSource? thumbnail = null) - { - var secondaryBrush = ThemeResourceHelper.Secondary(this); - var hintBg = ThemeResourceHelper.Hint(this); - - var chip = new Border - { - Background = hintBg, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(4), - Margin = new Thickness(0, 0, 4, 4), - }; - - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - - // 썸네일 이미지 - if (thumbnail != null) - { - sp.Children.Add(new System.Windows.Controls.Image - { - Source = thumbnail, - MaxHeight = 48, MaxWidth = 64, - Stretch = Stretch.Uniform, - Margin = new Thickness(0, 0, 4, 0), - }); - } - else - { - // base64에서 썸네일 생성 - sp.Children.Add(new TextBlock - { - Text = "\uE8B9", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 16, - Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(2, 0, 4, 0), - }); - } - - sp.Children.Add(new TextBlock - { - Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush, - VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100, - TextTrimming = TextTrimming.CharacterEllipsis, - }); - - var capturedAttachment = attachment; - var capturedChip = chip; - var removeBtn = new Button - { - Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush }, - Background = Brushes.Transparent, BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), - }; - removeBtn.Click += (_, _) => - { - _pendingImages.Remove(capturedAttachment); - AttachedFilesPanel.Items.Remove(capturedChip); - if (_pendingImages.Count == 0 && _attachedFiles.Count == 0) - AttachedFilesPanel.Visibility = Visibility.Collapsed; - }; - sp.Children.Add(removeBtn); - chip.Child = sp; - - AttachedFilesPanel.Items.Add(chip); - AttachedFilesPanel.Visibility = Visibility.Visible; - } - - // ─── 슬래시 명령어 + 드래그 앤 드롭 → ChatWindow.SlashCommands.cs ─── - - // ─── /help 도움말 창 ───────────────────────────────────────────────── - - private void ShowSlashHelpWindow() - { - var bg = ThemeResourceHelper.Background(this); - var fg = ThemeResourceHelper.Primary(this); - var fg2 = ThemeResourceHelper.Secondary(this); - var accent = ThemeResourceHelper.Accent(this); - var itemBg = ThemeResourceHelper.ItemBg(this); - var hoverBg = ThemeResourceHelper.HoverBg(this); - - var win = new Window - { - Title = "AX Agent — 슬래시 명령어 도움말", - Width = 560, Height = 640, MinWidth = 440, MinHeight = 500, - WindowStyle = WindowStyle.None, AllowsTransparency = true, - Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize, - WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this, - Icon = Icon, - }; - - var mainBorder = new Border - { - Background = bg, CornerRadius = new CornerRadius(16), - BorderBrush = ThemeResourceHelper.Border(this), - BorderThickness = new Thickness(1), Margin = new Thickness(10), - Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 }, - }; - - var rootGrid = new Grid(); - rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) }); - rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - // 헤더 - var headerBorder = new Border - { - CornerRadius = new CornerRadius(16, 16, 0, 0), - Background = new LinearGradientBrush( - Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204), - new Point(0, 0), new Point(1, 1)), - Padding = new Thickness(20, 0, 20, 0), - }; - var headerGrid = new Grid(); - var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; - headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }); - headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center }); - headerGrid.Children.Add(headerStack); - - var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center }; - closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; - closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255)); - closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent; - closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); }; - headerGrid.Children.Add(closeBtn); - headerBorder.Child = headerGrid; - Grid.SetRow(headerBorder, 0); - rootGrid.Children.Add(headerBorder); - - // 콘텐츠 - var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) }; - var contentPanel = new StackPanel(); - - // 설명 - contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 }); - - // 공통 명령어 섹션 - AddHelpSection(contentPanel, "📌 공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, - ("/summary", "텍스트/문서를 핵심 포인트 중심으로 요약합니다."), - ("/translate", "텍스트를 영어로 번역합니다. 원문의 톤을 유지합니다."), - ("/explain", "내용을 쉽고 자세하게 설명합니다. 예시를 포함합니다."), - ("/fix", "맞춤법, 문법, 자연스러운 표현을 교정합니다.")); - - // 개발 명령어 섹션 - AddHelpSection(contentPanel, "🛠️ 개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg, - ("/review", "Git diff를 분석하여 버그, 성능, 보안 이슈를 찾습니다."), - ("/pr", "변경사항을 PR 설명 형식(Summary, Changes, Test Plan)으로 요약합니다."), - ("/test", "코드에 대한 단위 테스트를 자동 생성합니다."), - ("/structure", "프로젝트의 폴더/파일 구조를 분석하고 설명합니다."), - ("/build", "프로젝트를 빌드합니다. 오류 발생 시 분석합니다."), - ("/search", "자연어로 코드베이스를 시맨틱 검색합니다.")); - - // 스킬 명령어 섹션 - var skills = SkillService.Skills; - if (skills.Count > 0) - { - var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray(); - AddHelpSection(contentPanel, "⚡ 스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems); - } - - // 사용 팁 - contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) }); - var tipPanel = new StackPanel(); - tipPanel.Children.Add(new TextBlock { Text = "💡 사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) }); - var tips = new[] - { - "/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.", - "파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.", - "스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.", - "Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.", - }; - foreach (var tip in tips) - { - tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 }); - } - contentPanel.Children.Add(tipPanel); - - scroll.Content = contentPanel; - Grid.SetRow(scroll, 1); - rootGrid.Children.Add(scroll); - - mainBorder.Child = rootGrid; - win.Content = mainBorder; - // 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지) - headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch (Exception) { /* 드래그 이동 실패 — 마우스 릴리즈 시 발생 가능 */ } }; - win.ShowDialog(); - } - - private static void AddHelpSection(StackPanel parent, string title, string subtitle, - Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg, - params (string Cmd, string Desc)[] items) - { - parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) }); - parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) }); - - foreach (var (cmd, desc) in items) - { - var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) }; - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = ThemeResourceHelper.Consolas }; - Grid.SetColumn(cmdText, 0); - grid.Children.Add(cmdText); - - var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; - Grid.SetColumn(descText, 1); - grid.Children.Add(descText); - - row.Child = grid; - row.MouseEnter += (_, _) => row.Background = hoverBg; - row.MouseLeave += (_, _) => row.Background = itemBg; - parent.Children.Add(row); - } - } - - private async Task SendMessageAsync() - { - var rawText = InputBox.Text.Trim(); - - // 슬래시 칩이 활성화된 경우 명령어 앞에 붙임 - var text = _activeSlashCmd != null - ? (_activeSlashCmd + " " + rawText).Trim() - : rawText; - HideSlashChip(restoreText: false); - - if (string.IsNullOrEmpty(text) || _isStreaming) return; - - // placeholder 정리 - ClearPromptCardPlaceholder(); - - // 슬래시 명령어 처리 - var (slashSystem, displayText) = ParseSlashCommand(text); - - // 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처 - var originTab = _activeTab; - - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) _currentConversation = new ChatConversation { Tab = _activeTab }; - conv = _currentConversation; - } - - var userMsg = new ChatMessage { Role = "user", Content = text }; - lock (_convLock) conv.Messages.Add(userMsg); - - if (conv.Messages.Count(m => m.Role == "user") == 1) - conv.Title = text.Length > 30 ? text[..30] + "…" : text; - - UpdateChatTitle(); - AddMessageBubble("user", text); - InputBox.Text = ""; - EmptyState.Visibility = Visibility.Collapsed; - - // 대화 통계 기록 - Services.UsageStatisticsService.RecordChat(_activeTab); - - ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동 - PlayRainbowGlow(); // 무지개 글로우 애니메이션 - - _isStreaming = true; - BtnSend.IsEnabled = false; - BtnSend.Visibility = Visibility.Collapsed; - BtnStop.Visibility = Visibility.Visible; - if (_activeTab == "Cowork" || _activeTab == "Code") - BtnPause.Visibility = Visibility.Visible; - _streamCts = new CancellationTokenSource(); - - var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; - lock (_convLock) conv.Messages.Add(assistantMsg); - - // 어시스턴트 스트리밍 컨테이너 - var streamContainer = CreateStreamingContainer(out var streamText); - MessagePanel.Children.Add(streamContainer); - ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 - - var sb = new System.Text.StringBuilder(); - _activeStreamText = streamText; - _cachedStreamContent = ""; - _displayedLength = 0; - _cursorVisible = true; - _aiIconPulseStopped = false; - _cursorTimer.Start(); - _typingTimer.Start(); - _streamStartTime = DateTime.UtcNow; - _elapsedTimer.Start(); - SetStatus("응답 생성 중...", spinning: true); - - // ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ── - ModelRouteResult? routeResult = null; - - try - { - List sendMessages; - lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); - - // 시스템 명령어가 있으면 삽입 - if (!string.IsNullOrEmpty(conv.SystemCommand)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); - - // 슬래시 명령어 시스템 프롬프트 삽입 - if (!string.IsNullOrEmpty(slashSystem)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = slashSystem }); - - // 첨부 파일 컨텍스트 삽입 - if (_attachedFiles.Count > 0) - { - var fileContext = BuildFileContextPrompt(); - if (!string.IsNullOrEmpty(fileContext)) - { - var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); - if (lastUserIdx >= 0) - sendMessages[lastUserIdx] = new ChatMessage { Role = "user", Content = sendMessages[lastUserIdx].Content + fileContext }; - } - // 첨부 파일 목록 기록 후 항상 정리 (파일 읽기 실패해도) - userMsg.AttachedFiles = _attachedFiles.ToList(); - _attachedFiles.Clear(); - RefreshAttachedFilesUI(); - } - - // ── 이미지 첨부 ── - if (_pendingImages.Count > 0) - { - userMsg.Images = _pendingImages.ToList(); - // 마지막 사용자 메시지에 이미지 데이터 연결 - var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); - if (lastUserIdx >= 0) - sendMessages[lastUserIdx] = new ChatMessage - { - Role = "user", - Content = sendMessages[lastUserIdx].Content, - Images = _pendingImages.ToList(), - }; - _pendingImages.Clear(); - AttachedFilesPanel.Items.Clear(); - if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; - } - - // ── 자동 모델 라우팅 ── - if (Llm.EnableAutoRouter) - { - routeResult = _router.Route(text); - if (routeResult != null) - { - _llm.PushRouteOverride(routeResult.Service, routeResult.Model); - SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true); - } - } - - if (_activeTab is "Cowork" or "Code") - { - // Phase 34: Cowork/Code 공통 에이전트 루프 실행 - var agentResponse = await RunAgentLoopAsync(_activeTab, sendMessages, _streamCts!.Token); - sb.Append(agentResponse); - assistantMsg.Content = agentResponse; - StopAiIconPulse(); - _cachedStreamContent = agentResponse; - } - else if (Llm.Streaming) - { - await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) - { - sb.Append(chunk); - StopAiIconPulse(); - _cachedStreamContent = sb.ToString(); - // UI 스레드에 제어 양보 — DispatcherTimer가 화면 갱신할 수 있도록 - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - _cachedStreamContent = sb.ToString(); - assistantMsg.Content = _cachedStreamContent; - - // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) - var drainStart = DateTime.UtcNow; - while (_displayedLength < _cachedStreamContent.Length - && (DateTime.UtcNow - drainStart).TotalMilliseconds < 600) - { - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - } - else - { - var response = await _llm.SendAsync(sendMessages, _streamCts.Token); - sb.Append(response); - assistantMsg.Content = response; - } - } - catch (OperationCanceledException) - { - if (sb.Length == 0) sb.Append("(취소됨)"); - assistantMsg.Content = sb.ToString(); - } - catch (Exception ex) - { - var errMsg = $"⚠ 오류: {ex.Message}"; - sb.Clear(); sb.Append(errMsg); - assistantMsg.Content = errMsg; - AddRetryButton(); - } - finally - { - // 자동 라우팅 오버라이드 해제 - if (routeResult != null) - { - _llm.ClearRouteOverride(); - UpdateModelLabel(); - } - - _cursorTimer.Stop(); - _elapsedTimer.Stop(); - _typingTimer.Stop(); - HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 - StopRainbowGlow(); // 레인보우 글로우 종료 - _activeStreamText = null; - _elapsedLabel = null; - _cachedStreamContent = ""; - _isStreaming = false; - BtnSend.IsEnabled = true; - BtnStop.Visibility = Visibility.Collapsed; - BtnSend.Visibility = Visibility.Visible; - _streamCts?.Dispose(); - _streamCts = null; - SetStatusIdle(); - } - - // 스트리밍 plaintext → 마크다운 렌더링으로 교체 - FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); - AutoScrollIfNeeded(); - - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - _tabConversationId[originTab] = conv.Id; - RefreshConversationList(); - } - - // ─── Agent Support → ChatWindow.AgentSupport.cs ─── - // ─── Task Decomposition UI → ChatWindow.TaskDecomposition.cs ─── - // ─── 응답 재생성 ────────────────────────────────────────────────────── - - private async Task RegenerateLastAsync() - { - if (_isStreaming) return; - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // 마지막 assistant 메시지 제거 - lock (_convLock) - { - if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - - // UI에서 마지막 AI 응답 제거 - if (MessagePanel.Children.Count > 0) - MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); - - // 재전송 - await SendRegenerateAsync(conv); - } - - /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다. - private void ShowRetryWithFeedbackInput() - { - if (_isStreaming) return; - var accentBrush = ThemeResourceHelper.Accent(this); - var itemBg = ThemeResourceHelper.ItemBg(this); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var borderBrush = ThemeResourceHelper.Border(this); - - var container = new Border - { - Margin = new Thickness(40, 4, 40, 8), - Padding = new Thickness(14, 10, 14, 10), - CornerRadius = new CornerRadius(12), - Background = itemBg, - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - - var stack = new StackPanel(); - stack.Children.Add(new TextBlock - { - Text = "어떻게 수정하면 좋을지 알려주세요:", - FontSize = 12, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 6), - }); - - var textBox = new TextBox - { - MinHeight = 38, - MaxHeight = 80, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - FontSize = 13, - Background = ThemeResourceHelper.Background(this), - Foreground = primaryText, - CaretBrush = primaryText, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6, 10, 6), - }; - stack.Children.Add(textBox); - - var btnRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 8, 0, 0), - }; - - var sendBtn = new Border - { - Background = accentBrush, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(14, 6, 14, 6), - Cursor = Cursors.Hand, - Margin = new Thickness(6, 0, 0, 0), - }; - sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, 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; - MessagePanel.Children.Remove(container); - _ = RetryWithFeedbackAsync(feedback); - }; - - var cancelBtn = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 6, 12, 6), - Cursor = Cursors.Hand, - }; - cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; - cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container); - - btnRow.Children.Add(cancelBtn); - btnRow.Children.Add(sendBtn); - stack.Children.Add(btnRow); - container.Child = stack; - - ApplyMessageEntryAnimation(container); - MessagePanel.Children.Add(container); - ForceScrollToEnd(); - textBox.Focus(); - } - - /// 사용자 피드백과 함께 마지막 응답을 재생성합니다. - private async Task RetryWithFeedbackAsync(string feedback) - { - if (_isStreaming) return; - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // 마지막 assistant 메시지 제거 - lock (_convLock) - { - if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - - // UI에서 마지막 AI 응답 제거 - if (MessagePanel.Children.Count > 0) - MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); - - // 피드백을 사용자 메시지로 추가 - var feedbackMsg = new ChatMessage - { - Role = "user", - Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요." - }; - lock (_convLock) conv.Messages.Add(feedbackMsg); - - // 피드백 메시지 UI 표시 - AddMessageBubble("user", $"[수정 요청] {feedback}", true); - - // 재전송 - await SendRegenerateAsync(conv); - } - - private async Task SendRegenerateAsync(ChatConversation conv) - { - _isStreaming = true; - BtnSend.IsEnabled = false; - BtnSend.Visibility = Visibility.Collapsed; - BtnStop.Visibility = Visibility.Visible; - _streamCts = new CancellationTokenSource(); - - var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; - lock (_convLock) conv.Messages.Add(assistantMsg); - - var streamContainer = CreateStreamingContainer(out var streamText); - MessagePanel.Children.Add(streamContainer); - ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 - - var sb = new System.Text.StringBuilder(); - _activeStreamText = streamText; - _cachedStreamContent = ""; - _displayedLength = 0; - _cursorVisible = true; - _aiIconPulseStopped = false; - _cursorTimer.Start(); - _typingTimer.Start(); - _streamStartTime = DateTime.UtcNow; - _elapsedTimer.Start(); - SetStatus("에이전트 작업 중...", spinning: true); - - try - { - List sendMessages; - lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); - if (!string.IsNullOrEmpty(conv.SystemCommand)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); - - await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) - { - sb.Append(chunk); - StopAiIconPulse(); - _cachedStreamContent = sb.ToString(); - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - _cachedStreamContent = sb.ToString(); - assistantMsg.Content = _cachedStreamContent; - - // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) - var drainStart2 = DateTime.UtcNow; - while (_displayedLength < _cachedStreamContent.Length - && (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600) - { - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - } - catch (OperationCanceledException) - { - if (sb.Length == 0) sb.Append("(취소됨)"); - assistantMsg.Content = sb.ToString(); - } - catch (Exception ex) - { - var errMsg = $"⚠ 오류: {ex.Message}"; - sb.Clear(); sb.Append(errMsg); - assistantMsg.Content = errMsg; - AddRetryButton(); - } - finally - { - _cursorTimer.Stop(); - _elapsedTimer.Stop(); - _typingTimer.Stop(); - HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 - StopRainbowGlow(); // 레인보우 글로우 종료 - _activeStreamText = null; - _elapsedLabel = null; - _cachedStreamContent = ""; - _isStreaming = false; - BtnSend.IsEnabled = true; - BtnStop.Visibility = Visibility.Collapsed; - BtnSend.Visibility = Visibility.Visible; - _streamCts?.Dispose(); - _streamCts = null; - SetStatusIdle(); - } - - FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); - AutoScrollIfNeeded(); - - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - _tabConversationId[conv.Tab ?? _activeTab] = conv.Id; - RefreshConversationList(); - } - - /// 메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (최소 500, 최대 1200). - private double GetMessageMaxWidth() - { - var scrollWidth = MessageScroll.ActualWidth; - if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값 - // 좌우 마진(40+80=120)을 빼고 전체의 90% - var maxW = (scrollWidth - 120) * 0.90; - return Math.Clamp(maxW, 500, 1200); - } - - private StackPanel CreateStreamingContainer(out TextBlock streamText) - { - var msgMaxWidth = GetMessageMaxWidth(); - var container = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Left, - Width = msgMaxWidth, - MaxWidth = msgMaxWidth, - Margin = new Thickness(40, 8, 80, 8), - Opacity = 0, - RenderTransform = new TranslateTransform(0, 10) - }; - - // 컨테이너 페이드인 + 슬라이드 업 - container.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); - ((TranslateTransform)container.RenderTransform).BeginAnimation( - TranslateTransform.YProperty, - new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) - { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); - - var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var aiIcon = new TextBlock - { - Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, - Foreground = ThemeResourceHelper.Accent(this), - VerticalAlignment = VerticalAlignment.Center - }; - // AI 아이콘 펄스 애니메이션 (응답 대기 중) - aiIcon.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) - { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, - EasingFunction = new SineEase() }); - _activeAiIcon = aiIcon; - Grid.SetColumn(aiIcon, 0); - headerGrid.Children.Add(aiIcon); - - var (streamAgentName, _, _) = GetAgentIdentity(); - var aiNameTb = new TextBlock - { - Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = ThemeResourceHelper.Accent(this), - Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center - }; - Grid.SetColumn(aiNameTb, 1); - headerGrid.Children.Add(aiNameTb); - - // 실시간 경과 시간 (헤더 우측) - _elapsedLabel = new TextBlock - { - Text = "0s", - FontSize = 10.5, - Foreground = ThemeResourceHelper.Secondary(this), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.5, - }; - Grid.SetColumn(_elapsedLabel, 2); - headerGrid.Children.Add(_elapsedLabel); - - container.Children.Add(headerGrid); - - streamText = new TextBlock - { - Text = "\u258c", // 블록 커서만 표시 (첫 청크 전) - FontSize = 13.5, - Foreground = ThemeResourceHelper.Secondary(this), - TextWrapping = TextWrapping.Wrap, LineHeight = 22, - }; - container.Children.Add(streamText); - return container; - } - - // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── - - private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) - { - // 스트리밍 plaintext 블록 제거 - container.Children.Remove(streamText); - - // 마크다운 렌더링 - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var codeBgBrush = ThemeResourceHelper.Hint(this); - - var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); - mdPanel.Margin = new Thickness(0, 0, 0, 4); - mdPanel.Opacity = 0; - container.Children.Add(mdPanel); - mdPanel.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); - - // 액션 버튼 바 + 토큰 표시 - var btnColor = ThemeResourceHelper.Secondary(this); - var capturedContent = finalContent; - var actionBar = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 6, 0, 0) - }; - actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => - { - try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ } - })); - actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); - actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); - AddLinkedFeedbackButtons(actionBar, btnColor, message); - - container.Children.Add(actionBar); - - // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) - var elapsed = DateTime.UtcNow - _streamStartTime; - var elapsedText = elapsed.TotalSeconds < 60 - ? $"{elapsed.TotalSeconds:0.#}s" - : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; - - var usage = _llm.LastTokenUsage; - // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 - var isAgentTab = _activeTab is "Cowork" or "Code"; - var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 - ? _agentCumulativeInputTokens - : usage?.PromptTokens ?? 0; - var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0 - ? _agentCumulativeOutputTokens - : usage?.CompletionTokens ?? 0; - - if (displayInput > 0 || displayOutput > 0) - { - UpdateStatusTokens(displayInput, displayOutput); - Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput); - } - string tokenText; - if (displayInput > 0 || displayOutput > 0) - tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; - else if (usage != null) - tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; - else - tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; - - var metaText = new TextBlock - { - Text = $"{elapsedText} · {tokenText}", - FontSize = 10.5, - Foreground = ThemeResourceHelper.Secondary(this), - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 6, 0, 0), - Opacity = 0.6, - }; - container.Children.Add(metaText); - - // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 - var chips = ParseSuggestionChips(finalContent); - if (chips.Count > 0) - { - var chipPanel = new WrapPanel - { - Margin = new Thickness(0, 8, 0, 4), - HorizontalAlignment = HorizontalAlignment.Left, - }; - foreach (var (num, label) in chips) - { - var chipBorder = new Border - { - Background = ThemeResourceHelper.ItemBg(this), - BorderBrush = ThemeResourceHelper.Border(this), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14, 7, 14, 7), - Margin = new Thickness(0, 0, 8, 6), - Cursor = Cursors.Hand, - RenderTransformOrigin = new Point(0.5, 0.5), - RenderTransform = new ScaleTransform(1, 1), - }; - chipBorder.Child = new TextBlock - { - Text = $"{num}. {label}", - FontSize = 12.5, - Foreground = ThemeResourceHelper.Primary(this), - }; - - var chipHover = ThemeResourceHelper.HoverBg(this); - var chipNormal = ThemeResourceHelper.ItemBg(this); - chipBorder.MouseEnter += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } - }; - chipBorder.MouseLeave += (s, _) => - { - if (s is Border b && b.RenderTransform is ScaleTransform st) - { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } - }; - - var capturedLabel = $"{num}. {label}"; - var capturedPanel = chipPanel; - chipBorder.MouseLeftButtonDown += (_, _) => - { - // 칩 패널 제거 (1회용) - if (capturedPanel.Parent is Panel parent) - parent.Children.Remove(capturedPanel); - // 선택한 옵션을 사용자 메시지로 전송 - InputBox.Text = capturedLabel; - _ = SendMessageAsync(); - }; - chipPanel.Children.Add(chipBorder); - } - container.Children.Add(chipPanel); - } - } - - /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) - private static List<(string Num, string Label)> ParseSuggestionChips(string content) - { - var chips = new List<(string, string)>(); - if (string.IsNullOrEmpty(content)) return chips; - - var lines = content.Split('\n'); - // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) - var candidates = new List<(string, string)>(); - var lastBlockStart = -1; - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i].Trim(); - // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 - var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); - if (m.Success) - { - if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) - { - if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - else - { - // 새로운 블록 시작 - lastBlockStart = i; - candidates.Clear(); - candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); - } - } - else if (!string.IsNullOrWhiteSpace(line)) - { - // 번호 목록이 아닌 줄이 나오면 블록 리셋 - lastBlockStart = -1; - candidates.Clear(); - } - // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) - } - - // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 - if (candidates.Count >= 2 && candidates.Count <= 10) - chips.AddRange(candidates); - - return chips; - } - - /// 토큰 수를 k/m 단위로 포맷 - private static string FormatTokenCount(int count) => count switch - { - >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", - >= 1_000 => $"{count / 1_000.0:0.#}k", - _ => count.ToString(), - }; - - /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) - private static int EstimateTokenCount(string text) - { - if (string.IsNullOrEmpty(text)) return 0; - // 한국어 문자 비율에 따라 가중 - int cjk = 0; - foreach (var c in text) - if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; - double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; - double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 - return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); - } - - // ─── 생성 중지 ────────────────────────────────────────────────────── - - private void StopGeneration() - { - _streamCts?.Cancel(); - } - - // ─── 대화 내보내기 ────────────────────────────────────────────────── - - // ─── 대화 분기 (Fork) ────────────────────────────────────────────── - - private void ForkConversation(ChatConversation source, int atIndex) - { - var branchCount = _storage.LoadAllMeta() - .Count(m => m.ParentId == source.Id) + 1; - - var fork = new ChatConversation - { - Title = $"{source.Title} (분기 {branchCount})", - Tab = source.Tab, - Category = source.Category, - WorkFolder = source.WorkFolder, - SystemCommand = source.SystemCommand, - ParentId = source.Id, - BranchLabel = $"분기 {branchCount}", - BranchAtIndex = atIndex, - }; - - // 분기 시점까지의 메시지 복제 - for (int i = 0; i <= atIndex && i < source.Messages.Count; i++) - { - var m = source.Messages[i]; - fork.Messages.Add(new ChatMessage - { - Role = m.Role, - Content = m.Content, - Timestamp = m.Timestamp, - }); - } - - try - { - _storage.Save(fork); - ShowToast($"분기 생성: {fork.Title}"); - - // 분기 대화로 전환 - lock (_convLock) _currentConversation = fork; - ChatTitle.Text = fork.Title; - RenderMessages(); - RefreshConversationList(); - } - catch (Exception ex) - { - ShowToast($"분기 실패: {ex.Message}", "\uE783"); - } - } - - // ─── 커맨드 팔레트 ───────────────────────────────────────────────── - - private void OpenCommandPalette() - { - var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; - palette.ShowDialog(); - } - - private void ExecuteCommand(string commandId) - { - switch (commandId) - { - case "tab:chat": TabChat.IsChecked = true; break; - case "tab:cowork": TabCowork.IsChecked = true; break; - case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; - case "new_conversation": StartNewConversation(); break; - case "search_conversation": ToggleMessageSearch(); break; - case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; - case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; - case "open_statistics": new StatisticsWindow().Show(); break; - case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; - case "toggle_devmode": - var llm = Llm; - llm.DevMode = !llm.DevMode; - _settings.Save(); - UpdateAnalyzerButtonVisibility(); - ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); - break; - case "open_audit_log": - try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ } - break; - case "paste_clipboard": - try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ } - break; - case "export_conversation": ExportConversation(); break; - } - } - - private void ExportConversation() - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null || conv.Messages.Count == 0) return; - - var dlg = new Microsoft.Win32.SaveFileDialog - { - FileName = $"{conv.Title}", - DefaultExt = ".md", - Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" - }; - if (dlg.ShowDialog() != true) return; - - var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); - string content; - - if (ext == ".json") - { - content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }); - } - else if (dlg.FileName.EndsWith(".pdf.html")) - { - // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 - content = PdfExportService.BuildHtml(conv); - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - PdfExportService.OpenInBrowser(dlg.FileName); - ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); - return; - } - else if (ext == ".html") - { - content = ExportToHtml(conv); - } - else - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"# {conv.Title}"); - sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); - sb.AppendLine(); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var label = msg.Role == "user" ? "**사용자**" : "**AI**"; - sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); - sb.AppendLine(); - sb.AppendLine(msg.Content); - if (msg.AttachedFiles is { Count: > 0 }) - { - sb.AppendLine(); - sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); - } - sb.AppendLine(); - sb.AppendLine("---"); - sb.AppendLine(); - } - content = sb.ToString(); - } - - System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); - } - - private static string ExportToHtml(ChatConversation conv) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(""); - sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); - sb.AppendLine(""); - sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); - sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); - - foreach (var msg in conv.Messages) - { - if (msg.Role == "system") continue; - var cls = msg.Role == "user" ? "user" : "ai"; - var label = msg.Role == "user" ? "사용자" : "AI"; - sb.AppendLine($"
"); - sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); - sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); - sb.AppendLine("
"); - } - - sb.AppendLine(""); - return sb.ToString(); - } - - // ─── 버튼 이벤트 ────────────────────────────────────────────────────── - - private void ChatWindow_KeyDown(object sender, KeyEventArgs e) - { - var mod = Keyboard.Modifiers; - - // Ctrl 단축키 - if (mod == ModifierKeys.Control) - { - switch (e.Key) - { - case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.W: Close(); e.Handled = true; break; - case Key.E: ExportConversation(); e.Handled = true; break; - case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; - case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.F: ToggleMessageSearch(); e.Handled = true; break; - case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; - case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; - case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; - } - } - - // Ctrl+Shift 단축키 - if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) - { - switch (e.Key) - { - case Key.C: - // 마지막 AI 응답 복사 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv != null) - { - var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); - if (lastAi != null) - try { Clipboard.SetText(lastAi.Content); } catch (Exception) { /* 클립보드 접근 실패 */ } - } - e.Handled = true; - break; - case Key.R: - // 마지막 응답 재생성 - _ = RegenerateLastAsync(); - e.Handled = true; - break; - case Key.D: - // 모든 대화 삭제 - BtnDeleteAll_Click(this, new RoutedEventArgs()); - e.Handled = true; - break; - case Key.P: - // 커맨드 팔레트 - OpenCommandPalette(); - e.Handled = true; - break; - } - } - - // Escape: 검색 바 닫기 또는 스트리밍 중지 - if (e.Key == Key.Escape) - { - if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } - else if (_isStreaming) { StopGeneration(); e.Handled = true; } - } - - // 슬래시 명령 팝업 키 처리 - if (SlashPopup.IsOpen) - { - if (e.Key == Key.Escape) - { - SlashPopup.IsOpen = false; - _slashSelectedIndex = -1; - e.Handled = true; - } - else if (e.Key == Key.Up) - { - SlashPopup_ScrollByDelta(120); // 위로 1칸 - e.Handled = true; - } - else if (e.Key == Key.Down) - { - SlashPopup_ScrollByDelta(-120); // 아래로 1칸 - e.Handled = true; - } - else if (e.Key == Key.Enter && _slashSelectedIndex >= 0) - { - e.Handled = true; - ExecuteSlashSelectedItem(); - } - } - } - - private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); - - private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (_agentLoop.IsPaused) - { - _agentLoop.Resume(); - PauseIcon.Text = "\uE769"; // 일시정지 아이콘 - BtnPause.ToolTip = "일시정지"; - } - else - { - _ = _agentLoop.PauseAsync(); - PauseIcon.Text = "\uE768"; // 재생 아이콘 - BtnPause.ToolTip = "재개"; - } - } - private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); - - // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── - - private List _searchMatchIndices = new(); - private int _searchCurrentIndex = -1; - - private void ToggleMessageSearch() - { - if (MessageSearchBar.Visibility == Visibility.Visible) - CloseMessageSearch(); - else - { - MessageSearchBar.Visibility = Visibility.Visible; - SearchTextBox.Focus(); - SearchTextBox.SelectAll(); - } - } - - private void CloseMessageSearch() - { - MessageSearchBar.Visibility = Visibility.Collapsed; - SearchTextBox.Text = ""; - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - // 하이라이트 제거 - ClearSearchHighlights(); - } - - private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - var query = SearchTextBox.Text.Trim(); - if (string.IsNullOrEmpty(query)) - { - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - ClearSearchHighlights(); - return; - } - - // 현재 대화의 메시지에서 검색 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - _searchMatchIndices.Clear(); - for (int i = 0; i < conv.Messages.Count; i++) - { - if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) - _searchMatchIndices.Add(i); - } - - if (_searchMatchIndices.Count > 0) - { - _searchCurrentIndex = 0; - SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - else - { - _searchCurrentIndex = -1; - SearchResultCount.Text = "결과 없음"; - } - } - - private void SearchPrev_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchNext_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); - - private void HighlightSearchResult() - { - if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; - var msgIndex = _searchMatchIndices[_searchCurrentIndex]; - - // MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤 - // 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로 - // (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동 - if (msgIndex < MessagePanel.Children.Count) - { - var element = MessagePanel.Children[msgIndex] as FrameworkElement; - element?.BringIntoView(); - } - else if (MessagePanel.Children.Count > 0) - { - // 범위 밖이면 마지막 자식으로 이동 - (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); - } - } - - private void ClearSearchHighlights() - { - // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 - } - - // ─── 에러 복구 재시도 버튼 ────────────────────────────────────────────── - - private void AddRetryButton() - { - Dispatcher.Invoke(() => - { - var retryBorder = new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(40, 4, 80, 4), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = System.Windows.Input.Cursors.Hand, - }; - var retrySp = new StackPanel { Orientation = Orientation.Horizontal }; - retrySp.Children.Add(new TextBlock - { - Text = "\uE72C", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), - }); - retrySp.Children.Add(new TextBlock - { - Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), - VerticalAlignment = VerticalAlignment.Center, - }); - retryBorder.Child = retrySp; - retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); }; - retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); }; - retryBorder.MouseLeftButtonUp += (_, _) => - { - lock (_convLock) - { - if (_currentConversation != null) - { - var lastIdx = _currentConversation.Messages.Count - 1; - if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant") - _currentConversation.Messages.RemoveAt(lastIdx); - } - } - _ = RegenerateLastAsync(); - }; - MessagePanel.Children.Add(retryBorder); - ForceScrollToEnd(); - }); - } - - // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── - - private void ShowMessageContextMenu(string content, string role) - { - var menu = CreateThemedContextMenu(); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - void AddItem(string icon, string label, Action action) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 12, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; - mi.Click += (_, _) => action(); - menu.Items.Add(mi); - } - - // 복사 - AddItem("\uE8C8", "텍스트 복사", () => - { - try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - // 마크다운 복사 - AddItem("\uE943", "마크다운 복사", () => - { - try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - // 인용하여 답장 - AddItem("\uE97A", "인용하여 답장", () => - { - var quote = content.Length > 200 ? content[..200] + "..." : content; - var lines = quote.Split('\n'); - var quoted = string.Join("\n", lines.Select(l => $"> {l}")); - InputBox.Text = quoted + "\n\n"; - InputBox.Focus(); - InputBox.CaretIndex = InputBox.Text.Length; - }); - - menu.Items.Add(new Separator()); - - // 재생성 (AI 응답만) - if (role == "assistant") - { - AddItem("\uE72C", "응답 재생성", () => _ = RegenerateLastAsync()); - } - - // 대화 분기 (Fork) - AddItem("\uE8A5", "여기서 분기", () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); - if (idx < 0) return; - - ForkConversation(conv, idx); - }); - - menu.Items.Add(new Separator()); - - // 이후 메시지 모두 삭제 - var msgContent = content; - var msgRole = role; - AddItem("\uE74D", "이후 메시지 모두 삭제", () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); - if (idx < 0) return; - - var removeCount = conv.Messages.Count - idx; - if (MessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", - "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) - return; - - conv.Messages.RemoveRange(idx, removeCount); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RenderMessages(); - ShowToast($"{removeCount}개 메시지 삭제됨"); - }); - - menu.IsOpen = true; - } - - // ─── 팁 알림 ────────────────────────────────────────────────────── - - private static readonly string[] Tips = - [ - "💡 작업 폴더에 AX.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", - "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", - "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", - "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", - "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", - "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", - "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", - "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", - "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", - "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", - "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", - "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", - "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", - "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", - "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", - "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", - "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", - "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", - "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", - "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", - "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", - "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", - "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", - "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", - ]; - private int _tipIndex; - private DispatcherTimer? _tipDismissTimer; - - private void ShowRandomTip() - { - if (!Llm.ShowTips) return; - if (_activeTab != "Cowork" && _activeTab != "Code") return; - - var tip = Tips[_tipIndex % Tips.Length]; - _tipIndex++; - - // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) - ShowTip(tip); - } - - private void ShowTip(string message) - { - _tipDismissTimer?.Stop(); - - ToastText.Text = message; - ToastIcon.Text = "\uE82F"; // 전구 아이콘 - ToastBorder.Visibility = Visibility.Visible; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - - var duration = Llm.TipDurationSeconds; - if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음) - - _tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; - _tipDismissTimer.Tick += (_, _) => - { - _tipDismissTimer.Stop(); - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - _tipDismissTimer.Start(); - } - - // ─── 프로젝트 문맥 파일 (AX.md) ────────────────────────────────── - - /// - /// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. - /// Claude Code와 동일한 파일명/형식을 사용합니다. - /// - private static string LoadProjectContext(string workFolder) - { - if (string.IsNullOrEmpty(workFolder)) return ""; - - // Phase 30-C: HierarchicalMemoryService — 4-layer 계층 메모리 통합 조회 - try - { - var hierMemory = new AxCopilot.Services.Agent.HierarchicalMemoryService(); - var merged = hierMemory.BuildMergedContext(workFolder, 8000); - if (!string.IsNullOrWhiteSpace(merged)) - { - // @include 지시어 해석 - if (merged.Contains("@")) - { - try - { - var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); - merged = resolver.ResolveAsync(merged, workFolder).GetAwaiter().GetResult(); - } - catch (Exception) { /* @include 실패 시 원본 유지 */ } - } - return $"\n## Project Context (Hierarchical Memory)\n{merged}\n"; - } - } - catch (Exception) { /* 계층 메모리 실패 시 레거시 폴백 */ } - - // 레거시 폴백: 단일 AX.md 탐색 (작업 폴더 → 상위 폴더 순) - var searchDir = workFolder; - for (int i = 0; i < 3; i++) - { - if (string.IsNullOrEmpty(searchDir)) break; - var filePath = System.IO.Path.Combine(searchDir, "AX.md"); - if (System.IO.File.Exists(filePath)) - { - try - { - var content = System.IO.File.ReadAllText(filePath); - if (content.Contains("@")) - { - try - { - var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); - var baseDir = System.IO.Path.GetDirectoryName(filePath) ?? workFolder; - content = resolver.ResolveAsync(content, baseDir).GetAwaiter().GetResult(); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - } - if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; - return $"\n## Project Context (from AX.md)\n{content}\n"; - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - } - searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; - } - return ""; - } - - /// Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입. - private string BuildPathBasedSkillSection() - { - try - { - // 현재 대화에서 언급된 파일 경로 추출 (최근 메시지에서) - var recentFiles = GetRecentlyMentionedFiles(); - if (recentFiles.Count == 0) return ""; - - var allSkills = AxCopilot.Services.Agent.SkillService.Skills; - var activator = new AxCopilot.Services.Agent.PathBasedSkillActivator(); - - var matchedSkills = new List(); - foreach (var file in recentFiles) - { - var matches = activator.GetActiveSkillsForFile(allSkills, file); - foreach (var m in matches) - { - if (!matchedSkills.Any(s => s.Name == m.Name)) - matchedSkills.Add(m); - } - } - - return activator.BuildSkillContextInjection(matchedSkills); - } - catch (Exception) { return ""; } - } - - /// 최근 대화에서 파일 경로를 추출합니다. - private List GetRecentlyMentionedFiles() - { - var files = new List(); - if (_currentConversation?.Messages == null) return files; - - // 최근 5개 메시지에서 파일 경로 패턴 탐색 - var recent = _currentConversation.Messages.TakeLast(5); - foreach (var msg in recent) - { - if (string.IsNullOrEmpty(msg.Content)) continue; - // 간단한 파일 경로 패턴: 확장자가 있는 경로 - var pathMatches = System.Text.RegularExpressions.Regex.Matches( - msg.Content, @"[\w./\\-]+\.\w{1,10}"); - foreach (System.Text.RegularExpressions.Match m in pathMatches) - { - var path = m.Value; - if (path.Contains('.') && !path.StartsWith("http")) - files.Add(path); - } - } - return files.Distinct().Take(10).ToList(); - } - - // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── - - private DispatcherTimer? _rainbowTimer; - private DateTime _rainbowStartTime; - - /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). - private void PlayRainbowGlow() - { - if (!Llm.EnableChatRainbowGlow) return; - - _rainbowTimer?.Stop(); - _rainbowStartTime = DateTime.UtcNow; - - // 페이드인 (빠르게) - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))); - - // 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속 - _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - _rainbowTimer.Tick += (_, _) => - { - var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; - - // 그라데이션 오프셋 회전 - var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게) - var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; - if (brush == null) return; - - // 시작/끝점 회전 (원형 이동) - var angle = shift * Math.PI * 2; - brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); - brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); - }; - _rainbowTimer.Start(); - } - - /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. - private void StopRainbowGlow() - { - _rainbowTimer?.Stop(); - _rainbowTimer = null; - if (InputGlowBorder.Opacity > 0) - { - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( - InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); - fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0; - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - } - } - - // ─── 토스트 알림 ────────────────────────────────────────────────────── - - private DispatcherTimer? _toastHideTimer; - - private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) - { - _toastHideTimer?.Stop(); - - ToastText.Text = message; - ToastIcon.Text = icon; - ToastBorder.Visibility = Visibility.Visible; - - // 페이드인 - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); - - // 자동 숨기기 - _toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; - _toastHideTimer.Tick += (_, _) => - { - _toastHideTimer.Stop(); - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - _toastHideTimer.Start(); - } - - // ─── 대화 주제 버튼 / 커스텀 프리셋 관리 → ChatWindow.Presets.cs ───── - - // ─── 프롬프트 템플릿 · 모델 전환 · 대화 관리 → ChatWindow.ModelSelector.cs ─── - - // ─── 미리보기/파일 탐색기 → ChatWindow.PreviewAndFiles.cs ─── - - // ─── 하단 상태바 ────────────────────────────────────────────────────── - - private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard; - - private void UpdateStatusBar(AgentEvent evt) - { - var toolLabel = evt.ToolName switch - { - "file_read" or "document_read" => "파일 읽기", - "file_write" => "파일 쓰기", - "file_edit" => "파일 수정", - "html_create" => "HTML 생성", - "xlsx_create" => "Excel 생성", - "docx_create" => "Word 생성", - "csv_create" => "CSV 생성", - "md_create" => "Markdown 생성", - "folder_map" => "폴더 탐색", - "glob" => "파일 검색", - "grep" => "내용 검색", - "process" => "명령 실행", - _ => evt.ToolName, - }; - - switch (evt.Type) - { - case AgentEventType.Thinking: - SetStatus("생각 중...", spinning: true); - break; - case AgentEventType.Planning: - SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true); - break; - case AgentEventType.ToolCall: - SetStatus($"{toolLabel} 실행 중...", spinning: true); - break; - case AgentEventType.ToolResult: - SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false); - break; - case AgentEventType.StepStart: - SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true); - break; - case AgentEventType.StepDone: - SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true); - break; - case AgentEventType.SkillCall: - SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true); - break; - case AgentEventType.Complete: - SetStatus("작업 완료", spinning: false); - StopStatusAnimation(); - break; - case AgentEventType.Error: - SetStatus("오류 발생", spinning: false); - StopStatusAnimation(); - break; - case AgentEventType.Paused: - SetStatus("⏸ 일시정지", spinning: false); - break; - case AgentEventType.Resumed: - SetStatus("▶ 재개됨", spinning: true); - break; - } - } - - private void SetStatus(string text, bool spinning) - { - if (StatusLabel != null) StatusLabel.Text = text; - if (spinning) StartStatusAnimation(); - } - - private void StartStatusAnimation() - { - if (_statusSpinStoryboard != null) return; - - var anim = new System.Windows.Media.Animation.DoubleAnimation - { - From = 0, To = 360, - Duration = TimeSpan.FromSeconds(2), - RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, - }; - - _statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard(); - System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim, - new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); - _statusSpinStoryboard.Children.Add(anim); - _statusSpinStoryboard.Begin(); - } - - private void StopStatusAnimation() - { - _statusSpinStoryboard?.Stop(); - _statusSpinStoryboard = null; - } - - private void SetStatusIdle() - { - StopStatusAnimation(); - if (StatusLabel != null) StatusLabel.Text = "대기 중"; - if (StatusElapsed != null) StatusElapsed.Text = ""; - if (StatusTokens != null) StatusTokens.Text = ""; - } - - private void UpdateStatusTokens(int inputTokens, int outputTokens) - { - if (StatusTokens == null) return; - var llm = Llm; - var (inCost, outCost) = Services.TokenEstimator.EstimateCost( - inputTokens, outputTokens, llm.Service, llm.Model); - var totalCost = inCost + outCost; - var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : ""; - StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}"; - } - - private static string TruncateForStatus(string? text, int max = 40) - { - if (string.IsNullOrEmpty(text)) return ""; - return text.Length <= max ? text : text[..max] + "…"; - } - - // ─── 헬퍼 ───────────────────────────────────────────────────────────── - private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) - { - var c = ThemeResourceHelper.HexColor(hex); - return new System.Windows.Media.SolidColorBrush(c); - } }