[Phase 40] ChatWindow 2차 파셜 클래스 분할 (94.5% 감소)

4,767줄 ChatWindow.xaml.cs를 7개 파셜 파일로 추가 분할
메인 파일: 4,767줄 → 262줄 (94.5% 감소)
전체 ChatWindow 파셜 파일: 15개

- ChatWindow.Controls.cs (595줄): 사용자정보, 스크롤, 제목편집, 탭전환
- ChatWindow.WorkFolder.cs (359줄): 작업폴더, 폴더 설정
- ChatWindow.PermissionMenu.cs (498줄): 권한, 파일첨부, 사이드바
- ChatWindow.ConversationList.cs (747줄): 대화목록, 제목편집, 검색
- ChatWindow.Sending.cs (720줄): 전송, 편집모드, 타이머
- ChatWindow.HelpCommands.cs (157줄): /help 도움말
- ChatWindow.ResponseHandling.cs (1,494줄): 응답재생성, 스트리밍, 토스트
- 빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 19:21:52 +09:00
parent 0c997f0149
commit 6448451d78
9 changed files with 4592 additions and 4506 deletions

View File

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

View File

@@ -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();
}
/// <summary>새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋).</summary>
private void ForceScrollToEnd()
{
_userScrolled = false;
Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background);
}
/// <summary>부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다.</summary>
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<string>();
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();
}
/// <summary>탭 전환 전 스트리밍 중이면 즉시 중단합니다.</summary>
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();
}
/// <summary>탭별로 마지막으로 활성화된 대화 ID를 기억.</summary>
private readonly Dictionary<string, string?> _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();
}
}

View File

@@ -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<string, (string Symbol, string Color)>(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<ConversationMeta>? _pendingConversations;
private void RenderConversationList(List<ConversationMeta> 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 ?? "";
}
}
}

View File

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

View File

@@ -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);
}
/// <summary>Cowork/Code 탭 진입 시 설정의 기본 권한을 적용.</summary>
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<string> 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);
}
}
/// <summary>첨부 파일 내용을 시스템 메시지로 변환합니다.</summary>
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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
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();
}
/// <summary>설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다.</summary>
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();
}
}
}
/// <summary>현재 _tabConversationId를 설정에 저장합니다.</summary>
private void SaveLastConversations()
{
var dict = new Dictionary<string, string>();
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();
}
}
/// <summary>클립보드에 이미지가 있으면 붙여넣기. 성공 시 true.</summary>
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;
}
}
/// <summary>이미지 미리보기 UI 추가.</summary>
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<ChatMessage> 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();
}
}

View File

@@ -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<string> _attachedFiles = new();
private readonly List<ImageAttachment> _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);
}
/// <summary>경로 유효성 검사 — 차단 대상 경로 필터링.</summary>
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;
}
/// <summary>테마에 맞는 ContextMenu를 생성합니다.</summary>
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),
};
}
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
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();
}
/// <summary>현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용.</summary>
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";
}
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
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}"); }
}
}

File diff suppressed because it is too large Load Diff