[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