[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:
@@ -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차)
|
||||
|
||||
|
||||
595
src/AxCopilot/Views/ChatWindow.Controls.cs
Normal file
595
src/AxCopilot/Views/ChatWindow.Controls.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
747
src/AxCopilot/Views/ChatWindow.ConversationList.cs
Normal file
747
src/AxCopilot/Views/ChatWindow.ConversationList.cs
Normal 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 ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
157
src/AxCopilot/Views/ChatWindow.HelpCommands.cs
Normal file
157
src/AxCopilot/Views/ChatWindow.HelpCommands.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
498
src/AxCopilot/Views/ChatWindow.PermissionMenu.cs
Normal file
498
src/AxCopilot/Views/ChatWindow.PermissionMenu.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
1494
src/AxCopilot/Views/ChatWindow.ResponseHandling.cs
Normal file
1494
src/AxCopilot/Views/ChatWindow.ResponseHandling.cs
Normal file
File diff suppressed because it is too large
Load Diff
720
src/AxCopilot/Views/ChatWindow.Sending.cs
Normal file
720
src/AxCopilot/Views/ChatWindow.Sending.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
359
src/AxCopilot/Views/ChatWindow.WorkFolder.cs
Normal file
359
src/AxCopilot/Views/ChatWindow.WorkFolder.cs
Normal 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
Reference in New Issue
Block a user