using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; using Microsoft.Win32; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 대화 분기 (Fork) ────────────────────────────────────────────── private void ForkConversation( ChatConversation source, int atIndex, string? branchHint = null, string? branchContextMessage = null, string? branchContextRunId = null) { var branchCount = _storage.LoadAllMeta() .Count(m => m.ParentId == source.Id) + 1; var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId) ?? new ChatConversation { Title = source.Title, Tab = source.Tab, Category = source.Category, WorkFolder = source.WorkFolder, SystemCommand = source.SystemCommand, ParentId = source.Id, BranchLabel = $"분기 {branchCount}", BranchAtIndex = atIndex, }; try { _storage.Save(fork); ShowToast($"분기 생성: {fork.Title}"); // 분기 대화로 전환 lock (_convLock) { _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork; SyncTabConversationIdsFromSession(); } ViewModel.ChatTitle = 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 = _settings.Settings.Llm; llm.DevMode = !llm.DevMode; ScheduleSettingsSave(); UpdateAnalyzerButtonVisibility(); ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); break; case "open_audit_log": try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { } break; case "paste_clipboard": try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { } 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.K: OpenSidebarSearch(); 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 { } } 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 (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; } else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } else if (_isStreaming) { StopGeneration(); e.Handled = true; } } // 슬래시 명령 팝업 키 처리 if (TryHandleSlashNavigationKey(e)) return; if (PermissionPopup.IsOpen && e.Key == Key.Escape) { PermissionPopup.IsOpen = false; e.Handled = true; } } private bool TryHandleSlashNavigationKey(KeyEventArgs e) { if (!SlashPopup.IsOpen) return false; switch (e.Key) { case Key.Escape: SlashPopup.IsOpen = false; _slashPalette.SelectedIndex = -1; e.Handled = true; return true; case Key.Up: SlashPopup_ScrollByDelta(120); e.Handled = true; return true; case Key.Down: SlashPopup_ScrollByDelta(-120); e.Handled = true; return true; case Key.PageUp: SlashPopup_ScrollByDelta(600); e.Handled = true; return true; case Key.PageDown: SlashPopup_ScrollByDelta(-600); e.Handled = true; return true; case Key.Home: { var visible = GetVisibleSlashOrderedIndices(); _slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches); UpdateSlashSelectionVisualState(); EnsureSlashSelectionVisible(); e.Handled = true; return true; } case Key.End: { var visible = GetVisibleSlashOrderedIndices(); _slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches); UpdateSlashSelectionVisualState(); EnsureSlashSelectionVisible(); e.Handled = true; return true; } case Key.Tab when _slashPalette.SelectedIndex >= 0: case Key.Enter when _slashPalette.SelectedIndex >= 0: ExecuteSlashSelectedItem(); e.Handled = true; return true; default: return false; } } private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { var activeLoop = GetAgentLoop(_activeTab); if (activeLoop.IsPaused) { activeLoop.Resume(); PauseIcon.Text = "\uE769"; // 일시정지 아이콘 BtnPause.ToolTip = "일시정지"; } else { _ = activeLoop.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 < GetTranscriptElementCount()) { var element = GetTranscriptElementAt(msgIndex) as FrameworkElement; element?.BringIntoView(); } else if (GetTranscriptElementCount() > 0) { // 범위 밖이면 마지막 자식으로 이동 (GetTranscriptElementAt(GetTranscriptElementCount() - 1) as FrameworkElement)?.BringIntoView(); } } private void ClearSearchHighlights() { // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 } // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── private void ShowMessageContextMenu(string content, string role) { var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); var (popup, panel) = CreateThemedPopupMenu(); // 복사 panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () => { try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { } })); // 마크다운 복사 panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () => { try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { } })); // 인용하여 답장 panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () => { 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; })); AddPopupMenuSeparator(panel, borderBrush); // 재생성 (AI 응답만) if (role == "assistant") { panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync())); } // 대화 분기 (Fork) panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () => { 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); })); AddPopupMenuSeparator(panel, borderBrush); // 이후 메시지 모두 삭제 var msgContent = content; var msgRole = role; panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () => { 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 (CustomMessageBox.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}개 메시지 삭제됨"); })); Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); } // ─── 팁 알림 ────────────────────────────────────────────────────── private static readonly string[] Tips = [ "💡 작업 폴더에 AGENTS.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 (!_settings.Settings.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(); _toastHideTimer?.Stop(); _toastHideTimer = null; 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 = _settings.Settings.Llm.TipDurationSeconds; if (duration <= 0) { // duration=0: 자동 숨기기 없음 — 기본 3초로 폴백하여 영구 잔류 방지 duration = 3; } var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; _tipDismissTimer = timer; timer.Tick += (_, _) => { if (_tipDismissTimer != timer) return; timer.Stop(); _tipDismissTimer = null; var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); }; timer.Start(); } }